http2py 0.1.36__tar.gz → 0.1.38__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 (36) hide show
  1. {http2py-0.1.36 → http2py-0.1.38}/PKG-INFO +1 -1
  2. {http2py-0.1.36 → http2py-0.1.38}/http2py/api_pkg_maker.py +8 -8
  3. {http2py-0.1.36 → http2py-0.1.38}/http2py/cli_maker.py +2 -3
  4. {http2py-0.1.36 → http2py-0.1.38}/http2py/client.py +56 -3
  5. {http2py-0.1.36 → http2py-0.1.38}/http2py/py2request.py +28 -1
  6. {http2py-0.1.36 → http2py-0.1.38}/pyproject.toml +1 -1
  7. {http2py-0.1.36 → http2py-0.1.38}/tests/test_generated_clients.py +1 -0
  8. http2py-0.1.38/tests/test_route_namespace.py +164 -0
  9. {http2py-0.1.36 → http2py-0.1.38}/.gitattributes +0 -0
  10. {http2py-0.1.36 → http2py-0.1.38}/.github/workflows/ci.yml +0 -0
  11. {http2py-0.1.36 → http2py-0.1.38}/.gitignore +0 -0
  12. {http2py-0.1.36 → http2py-0.1.38}/LICENSE +0 -0
  13. {http2py-0.1.36 → http2py-0.1.38}/README.md +0 -0
  14. {http2py-0.1.36 → http2py-0.1.38}/conftest.py +0 -0
  15. {http2py-0.1.36 → http2py-0.1.38}/http2py/__init__.py +0 -0
  16. {http2py-0.1.36 → http2py-0.1.38}/http2py/authentication.py +0 -0
  17. {http2py-0.1.36 → http2py-0.1.38}/http2py/constants.py +0 -0
  18. {http2py-0.1.36 → http2py-0.1.38}/http2py/decorators.py +0 -0
  19. {http2py-0.1.36 → http2py-0.1.38}/http2py/default_configs.py +0 -0
  20. {http2py-0.1.36 → http2py-0.1.38}/http2py/example_cli.py +0 -0
  21. {http2py-0.1.36 → http2py-0.1.38}/http2py/global_state.py +0 -0
  22. {http2py-0.1.36 → http2py-0.1.38}/http2py/http2py_cls.py +0 -0
  23. {http2py-0.1.36 → http2py-0.1.38}/http2py/resources/module_maker/func_tpl.txt +0 -0
  24. {http2py-0.1.36 → http2py-0.1.38}/http2py/testing_utils.py +0 -0
  25. {http2py-0.1.36 → http2py-0.1.38}/http2py/tests/__init__.py +0 -0
  26. {http2py-0.1.36 → http2py-0.1.38}/http2py/tests/api_pkg_maker_test.py +0 -0
  27. {http2py-0.1.36 → http2py-0.1.38}/http2py/tests/conftest.py +0 -0
  28. {http2py-0.1.36 → http2py-0.1.38}/http2py/tests/test_requirements.txt +0 -0
  29. {http2py-0.1.36 → http2py-0.1.38}/http2py/util.py +0 -0
  30. {http2py-0.1.36 → http2py-0.1.38}/tests/cli_goldens/mk_cli.json +0 -0
  31. {http2py-0.1.36 → http2py-0.1.38}/tests/spec_fixture.py +0 -0
  32. {http2py-0.1.36 → http2py-0.1.38}/tests/test_binary_payloads.py +0 -0
  33. {http2py-0.1.36 → http2py-0.1.38}/tests/test_ci_collection_contract.py +0 -0
  34. {http2py-0.1.36 → http2py-0.1.38}/tests/test_cli_surface.py +0 -0
  35. {http2py-0.1.36 → http2py-0.1.38}/tests/test_error_statuses.py +0 -0
  36. {http2py-0.1.36 → http2py-0.1.38}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: http2py
3
- Version: 0.1.36
3
+ Version: 0.1.38
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
@@ -79,11 +79,14 @@ _RESERVED_ROUTE_NAMES = {"api", "funcs"} # funcs: the module's own name
79
79
 
80
80
  FUNC_TPL = """
81
81
 
82
- @_apply_sig(api.{func_name})
82
+ @_apply_sig(api.routes[{func_name!r}])
83
83
  def {func_name}(*args, **kwargs):
84
- return api.{func_name}(*args, **kwargs)
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}
@@ -158,11 +161,7 @@ def object_ref(obj: Union[str, Callable]) -> str:
158
161
  def _route_names(api: HttpClient) -> list:
159
162
  """The method names ``api`` registered, each a plain identifier, since each is
160
163
  written into generated source."""
161
- names = [
162
- name
163
- for name, method in vars(api).items()
164
- if getattr(method, "method_spec", None)
165
- ]
164
+ names = list(api.routes)
166
165
  for name in names:
167
166
  if not _is_plain_identifier(name):
168
167
  raise ValueError(
@@ -219,6 +218,7 @@ def api_pkg_files(
219
218
  author=_cfg_value(pkg_author or "API Package Maker"),
220
219
  license=_cfg_value(pkg_license or "Apache"),
221
220
  spec_filename=SPEC_FILENAME,
221
+ min_http2py=MIN_HTTP2PY_VERSION,
222
222
  )
223
223
  funcs_code = FUNCS_FILE_TPL.format(
224
224
  spec_filename=SPEC_FILENAME,
@@ -143,8 +143,7 @@ def mk_cli(
143
143
  config_filename,
144
144
  binary_loads=binary_loads,
145
145
  )
146
- for methodname, method in client_details.__dict__.items()
147
- if getattr(method, "method_spec", None)
146
+ for method in client_details.routes.values()
148
147
  ]
149
148
  return cw.mk_parser(cli_methods, convention=CLI_CONVENTION)
150
149
 
@@ -208,7 +207,7 @@ def register_cli_method(
208
207
  auth_kwargs, expected_auth_kwargs, config_filename, profile
209
208
  )
210
209
  http_client = HttpClient(openapi_spec, binary_loads=binary_loads, **auth_kwargs)
211
- return getattr(http_client, methodname)(**kwargs)
210
+ return http_client.routes[methodname](**kwargs)
212
211
 
213
212
  method_sig.wrap(cli_method)
214
213
  cli_method.__name__ = methodname
@@ -4,6 +4,8 @@
4
4
  and exposes each declared route as a normal python method.
5
5
  """
6
6
 
7
+ import warnings
8
+
7
9
  from glom import glom
8
10
  from requests import request, get, Session
9
11
  from i2.errors import AuthorizationError
@@ -22,6 +24,15 @@ class HttpClient:
22
24
  """
23
25
  A client class meant as an interface to an HTTP service with one or more routes
24
26
  defined with an OpenAPI spec.
27
+
28
+ Every route is in ``client.routes`` (a ``{name: method}`` dict), and is also an
29
+ attribute of the client (``client.<name>``) as long as its name neither starts
30
+ with ``_`` nor is already an attribute the client works with: a spec, possibly
31
+ served by a remote, must not be able to replace the client's own attributes (such
32
+ as ``session`` or ``handle_request``). A route that cannot be an attribute is
33
+ still reachable as ``client.routes[name]``, and a warning says so. The purely
34
+ informational attributes (``title``, ``version``, ``base_url``), which the client
35
+ never reads after ``__init__``, can be taken by a route.
25
36
  """
26
37
 
27
38
  auth_type = ""
@@ -31,6 +42,10 @@ class HttpClient:
31
42
  refresh_url = ""
32
43
  refresh_input_keys = []
33
44
  session = None
45
+ login_args = None # set by init_security; declared so no route can take the name
46
+ login_response_keys = () # idem
47
+ # Set by __init__ but never read afterwards, so a route may take these names.
48
+ _route_shadowable_attrs = frozenset({"title", "version", "base_url"})
34
49
 
35
50
  def __init__(
36
51
  self,
@@ -96,10 +111,13 @@ class HttpClient:
96
111
  self.refresh_inputs = session_state.get("refresh_inputs")
97
112
  if self.refresh_inputs is None:
98
113
  raise ValueError("No refresh inputs provided when instantiating HttpClient")
114
+ self.routes = {}
99
115
  for pathname, path_spec in openapi_spec["paths"].items():
100
116
  url_template = self.base_url + pathname
101
117
  for http_method, openapi_method_spec in path_spec.items():
102
- 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
+ )
103
121
 
104
122
  def init_security(self, openapi_spec, **auth_kwargs):
105
123
  security = openapi_spec["security"]
@@ -127,7 +145,9 @@ class HttpClient:
127
145
  if jwt:
128
146
  self.set_header({"Authorization": f"Bearer {jwt}"})
129
147
 
130
- 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
+ ):
131
151
  content_type = None
132
152
  if "requestBody" in openapi_method_spec:
133
153
  content_type = next(
@@ -138,6 +158,7 @@ class HttpClient:
138
158
  method=http_method,
139
159
  url_template=url_template,
140
160
  content_type=content_type,
161
+ pathname=pathname,
141
162
  )
142
163
  func = mk_request_function(
143
164
  method_spec,
@@ -149,7 +170,39 @@ class HttpClient:
149
170
  func.method_spec = method_spec
150
171
  func.content_type = content_type
151
172
  funcname = func.__name__
152
- setattr(self, funcname, func.__get__(self))
173
+ method = func.__get__(self)
174
+ if "routes" not in vars(self): # e.g. a subclass that skips HttpClient.__init__
175
+ self.routes = {}
176
+ if funcname in self.routes:
177
+ warnings.warn(
178
+ f"Two routes are named {funcname!r} ({url_template!r} replaces the "
179
+ "earlier one): give each its own x-method_name.",
180
+ stacklevel=3,
181
+ )
182
+ can_be_attribute = self._can_be_route_attribute(funcname)
183
+ self.routes[funcname] = method
184
+ if can_be_attribute:
185
+ setattr(self, funcname, method)
186
+ else:
187
+ warnings.warn(
188
+ f"Route {funcname!r} is not set as an attribute of the client: the name "
189
+ f"starts with '_' or is already an attribute of {type(self).__name__}. "
190
+ f"Call it as client.routes[{funcname!r}](...).",
191
+ stacklevel=3,
192
+ )
193
+
194
+ def _can_be_route_attribute(self, name):
195
+ """Whether a route called ``name`` may be set as an attribute of the client:
196
+ the name is public, and is not an attribute the client works with already
197
+ (an earlier route of the same name, or a ``_route_shadowable_attrs`` one, can
198
+ be replaced)."""
199
+ if name.startswith("_"):
200
+ return False
201
+ if name in self.routes and vars(self).get(name) is self.routes[name]:
202
+ return True
203
+ if name in self._route_shadowable_attrs:
204
+ return True
205
+ return not (hasattr(type(self), name) or name in vars(self))
153
206
 
154
207
  def handle_request(self, method, url, **_request_kwargs):
155
208
  self.ensure_login()
@@ -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
@@ -607,6 +609,30 @@ def raw_response_on_error(func):
607
609
  return _func
608
610
 
609
611
 
612
+ def _identifier(text):
613
+ """``text`` as a public Python identifier: runs of other characters (and of ``_``) become ``_``,
614
+ a leading digit gets an ``op_`` prefix and a keyword a trailing ``_``."""
615
+ name = re.sub(r"[\W_]+", "_", text, flags=re.ASCII).strip("_")
616
+ if name[:1].isdigit():
617
+ name = f"op_{name}"
618
+ return f"{name}_" if keyword.iskeyword(name) else name
619
+
620
+
621
+ def _route_name(openapi_method_spec, method, pathname, url_template):
622
+ """The name of the function for a route: its ``x-method_name``, else its
623
+ ``operationId``, else the http method and the path (``get /users/{id}`` gives
624
+ ``get_users_id``), so that routes without ``x-method_name`` do not all share a name.
625
+ ``pathname`` is the path as the spec gives it, so that a server's base url does not
626
+ change the names; it defaults to the path of ``url_template``.
627
+ """
628
+ if openapi_method_spec.get("x-method_name"):
629
+ return openapi_method_spec["x-method_name"]
630
+ name = _identifier(openapi_method_spec.get("operationId") or "")
631
+ if pathname is None:
632
+ pathname = urlparse(url_template).path
633
+ return name or default_path_to_func_name(method, pathname)
634
+
635
+
610
636
  def mk_method_spec_from_openapi_method_spec(
611
637
  openapi_method_spec,
612
638
  method="post",
@@ -614,6 +640,7 @@ def mk_method_spec_from_openapi_method_spec(
614
640
  content_type=JSON_CONTENT_TYPE,
615
641
  input_trans=None,
616
642
  output_trans=None,
643
+ pathname=None,
617
644
  ):
618
645
  path_arg_names = []
619
646
  query_arg_names = []
@@ -671,7 +698,7 @@ def mk_method_spec_from_openapi_method_spec(
671
698
  path_arg_names=path_arg_names,
672
699
  query_arg_names=query_arg_names,
673
700
  body_arg_names=body_arg_names,
674
- method_name=openapi_method_spec.get("x-method_name", ""),
701
+ method_name=_route_name(openapi_method_spec, method, pathname, url_template),
675
702
  docstring=openapi_method_spec.get("description", ""),
676
703
  content_type=content_type,
677
704
  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.36"
9
+ version = "0.1.38"
10
10
  description = "Tools to create python binders to http web services."
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
@@ -125,6 +125,7 @@ def test_dispatch_cli_passes_binary_loads_to_the_client(monkeypatch, tmp_path):
125
125
  class FakeClient:
126
126
  def __init__(self, openapi_spec, **kwargs):
127
127
  seen.update(kwargs)
128
+ self.routes = {"plain": self.plain}
128
129
 
129
130
  def plain(self, **kwargs):
130
131
  return "ran"
@@ -0,0 +1,164 @@
1
+ """A route's name cannot replace an attribute of ``HttpClient`` (i2mint/http2py#23):
2
+ every route is in ``client.routes``, and only names that collide with nothing are
3
+ also set as attributes."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import copy
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ sys.path.insert(0, str(Path(__file__).parent))
14
+
15
+ from spec_fixture import SPEC # noqa: E402
16
+
17
+ from http2py import HttpClient # noqa: E402
18
+ from http2py.api_pkg_maker import api_pkg_files # noqa: E402
19
+
20
+
21
+ def _spec_naming_plain(name):
22
+ spec = copy.deepcopy(SPEC)
23
+ spec["paths"]["/plain"]["get"]["x-method_name"] = name
24
+ return spec
25
+
26
+
27
+ def _client(spec):
28
+ return HttpClient(spec, session_state={"session": object(), "refresh_inputs": {}})
29
+
30
+
31
+ @pytest.mark.parametrize(
32
+ "name",
33
+ ["openapi_spec", "session", "handle_request", "ensure_login", "binary_loads"]
34
+ + ["routes", "login_args", "login_response_keys", "verify_cert", "_private"],
35
+ )
36
+ def test_colliding_route_name_keeps_the_client_attribute(name):
37
+ spec = _spec_naming_plain(name)
38
+ with pytest.warns(UserWarning, match=f"client.routes\\[{name!r}\\]"):
39
+ client = _client(spec)
40
+ route = client.routes[name]
41
+ assert getattr(route, "method_spec", None)
42
+ assert getattr(client, name, None) is not route
43
+ assert client.openapi_spec is spec
44
+ assert callable(client.handle_request) and callable(client.ensure_login)
45
+
46
+
47
+ def test_non_colliding_routes_are_attributes_too():
48
+ client = _client(SPEC)
49
+ assert set(client.routes) == {"get_thing", "make", "plain"}
50
+ for name, route in client.routes.items():
51
+ assert getattr(client, name) is route
52
+ assert client.is_request_method(name)
53
+
54
+
55
+ def test_api_pkg_for_a_route_named_like_a_client_attribute():
56
+ # used to crash with "'function' object has no attribute 'get'"
57
+ with pytest.warns(UserWarning):
58
+ files = api_pkg_files(_spec_naming_plain("openapi_spec"), pkg_name="genpkg_c")
59
+ funcs = files["genpkg_c/funcs.py"]
60
+ assert "def openapi_spec(" in funcs
61
+ assert "api.routes['openapi_spec']" in funcs
62
+
63
+
64
+ @pytest.mark.parametrize("name", ["title", "version", "base_url"])
65
+ def test_informational_attributes_can_be_route_names(name):
66
+ import warnings
67
+
68
+ with warnings.catch_warnings():
69
+ warnings.simplefilter("error")
70
+ client = _client(_spec_naming_plain(name))
71
+ assert getattr(client, name) is client.routes[name]
72
+
73
+
74
+ def test_duplicate_route_names_warn():
75
+ with pytest.warns(UserWarning, match="Two routes are named 'get_thing'"):
76
+ client = _client(_spec_naming_plain("get_thing"))
77
+ assert set(client.routes) == {"get_thing", "make"}
78
+
79
+
80
+ def test_cli_calls_the_route_not_the_client_attribute(monkeypatch):
81
+ from http2py import cli_maker
82
+
83
+ spec = _spec_naming_plain("ensure_login")
84
+ with pytest.warns(UserWarning):
85
+ route = _client(spec).routes["ensure_login"]
86
+ class Sent(Exception):
87
+ pass
88
+
89
+ def handle_request(self, method, url, **kw):
90
+ raise Sent(url)
91
+
92
+ monkeypatch.setattr(HttpClient, "handle_request", handle_request)
93
+ with pytest.warns(UserWarning), pytest.raises(Sent, match="/plain$"):
94
+ cli_method = cli_maker.register_cli_method(spec, route, [])
95
+ cli_method(config="no-such-config", api_key="k")
96
+
97
+
98
+ def test_subclass_skipping_init_can_register_routes():
99
+ class Bare(HttpClient):
100
+ def __init__(self):
101
+ self.verify_cert, self.binary_loads, self.binary_dumps = True, None, None
102
+
103
+ bare = Bare()
104
+ spec = SPEC["paths"]["/plain"]["get"]
105
+ bare.register_method("https://example.com/plain", "get", spec)
106
+ assert bare.routes["plain"] is bare.plain
107
+
108
+
109
+ def _spec_without_names():
110
+ """A spec whose routes have no ``x-method_name`` (i2mint/http2py#26)."""
111
+ spec = copy.deepcopy(SPEC)
112
+ for path_item in spec["paths"].values():
113
+ for op in path_item.values():
114
+ op.pop("x-method_name", None)
115
+ return spec
116
+
117
+
118
+ def test_routes_without_x_method_name_get_distinct_names_from_method_and_path():
119
+ import warnings
120
+
121
+ with warnings.catch_warnings():
122
+ warnings.simplefilter("error")
123
+ client = _client(_spec_without_names())
124
+ assert len(client.routes) == len(SPEC["paths"]) == 3
125
+ assert "get_plain" in client.routes
126
+ for name, route in client.routes.items():
127
+ assert route.__name__ == name != "request_func"
128
+
129
+
130
+ def test_operation_id_names_a_route_without_x_method_name():
131
+ spec = _spec_without_names()
132
+ spec["paths"]["/plain"]["get"]["operationId"] = "list-plain.things"
133
+ assert "list_plain_things" in _client(spec).routes
134
+
135
+
136
+ def test_x_method_name_wins_over_operation_id():
137
+ spec = _spec_naming_plain("mine")
138
+ spec["paths"]["/plain"]["get"]["operationId"] = "theirs"
139
+ assert "mine" in _client(spec).routes
140
+
141
+
142
+ def test_api_pkg_for_a_spec_without_x_method_names():
143
+ files = api_pkg_files(_spec_without_names(), pkg_name="genpkg_d")
144
+ assert "def get_plain(" in files["genpkg_d/funcs.py"]
145
+
146
+
147
+ def test_generated_package_pins_the_http2py_that_has_client_routes():
148
+ from http2py.api_pkg_maker import MIN_HTTP2PY_VERSION
149
+
150
+ files = api_pkg_files(SPEC, pkg_name="genpkg_e")
151
+ assert f"http2py>={MIN_HTTP2PY_VERSION}" in files["setup.cfg"]
152
+
153
+
154
+ def test_derived_names_do_not_depend_on_the_server_url():
155
+ spec = _spec_without_names()
156
+ spec["servers"] = [{"url": "https://example.com/v1/api"}]
157
+ assert set(_client(spec).routes) == set(_client(_spec_without_names()).routes)
158
+
159
+
160
+ @pytest.mark.parametrize("operation_id", ["-", "___", "日本"])
161
+ def test_unusable_operation_id_falls_back_to_method_and_path(operation_id):
162
+ spec = _spec_without_names()
163
+ spec["paths"]["/plain"]["get"]["operationId"] = operation_id
164
+ assert "get_plain" in _client(spec).routes
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