http2py 0.1.36__tar.gz → 0.1.37__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.37}/PKG-INFO +1 -1
  2. {http2py-0.1.36 → http2py-0.1.37}/http2py/api_pkg_maker.py +3 -7
  3. {http2py-0.1.36 → http2py-0.1.37}/http2py/cli_maker.py +2 -3
  4. {http2py-0.1.36 → http2py-0.1.37}/http2py/client.py +49 -1
  5. {http2py-0.1.36 → http2py-0.1.37}/pyproject.toml +1 -1
  6. {http2py-0.1.36 → http2py-0.1.37}/tests/test_generated_clients.py +1 -0
  7. http2py-0.1.37/tests/test_route_namespace.py +106 -0
  8. {http2py-0.1.36 → http2py-0.1.37}/.gitattributes +0 -0
  9. {http2py-0.1.36 → http2py-0.1.37}/.github/workflows/ci.yml +0 -0
  10. {http2py-0.1.36 → http2py-0.1.37}/.gitignore +0 -0
  11. {http2py-0.1.36 → http2py-0.1.37}/LICENSE +0 -0
  12. {http2py-0.1.36 → http2py-0.1.37}/README.md +0 -0
  13. {http2py-0.1.36 → http2py-0.1.37}/conftest.py +0 -0
  14. {http2py-0.1.36 → http2py-0.1.37}/http2py/__init__.py +0 -0
  15. {http2py-0.1.36 → http2py-0.1.37}/http2py/authentication.py +0 -0
  16. {http2py-0.1.36 → http2py-0.1.37}/http2py/constants.py +0 -0
  17. {http2py-0.1.36 → http2py-0.1.37}/http2py/decorators.py +0 -0
  18. {http2py-0.1.36 → http2py-0.1.37}/http2py/default_configs.py +0 -0
  19. {http2py-0.1.36 → http2py-0.1.37}/http2py/example_cli.py +0 -0
  20. {http2py-0.1.36 → http2py-0.1.37}/http2py/global_state.py +0 -0
  21. {http2py-0.1.36 → http2py-0.1.37}/http2py/http2py_cls.py +0 -0
  22. {http2py-0.1.36 → http2py-0.1.37}/http2py/py2request.py +0 -0
  23. {http2py-0.1.36 → http2py-0.1.37}/http2py/resources/module_maker/func_tpl.txt +0 -0
  24. {http2py-0.1.36 → http2py-0.1.37}/http2py/testing_utils.py +0 -0
  25. {http2py-0.1.36 → http2py-0.1.37}/http2py/tests/__init__.py +0 -0
  26. {http2py-0.1.36 → http2py-0.1.37}/http2py/tests/api_pkg_maker_test.py +0 -0
  27. {http2py-0.1.36 → http2py-0.1.37}/http2py/tests/conftest.py +0 -0
  28. {http2py-0.1.36 → http2py-0.1.37}/http2py/tests/test_requirements.txt +0 -0
  29. {http2py-0.1.36 → http2py-0.1.37}/http2py/util.py +0 -0
  30. {http2py-0.1.36 → http2py-0.1.37}/tests/cli_goldens/mk_cli.json +0 -0
  31. {http2py-0.1.36 → http2py-0.1.37}/tests/spec_fixture.py +0 -0
  32. {http2py-0.1.36 → http2py-0.1.37}/tests/test_binary_payloads.py +0 -0
  33. {http2py-0.1.36 → http2py-0.1.37}/tests/test_ci_collection_contract.py +0 -0
  34. {http2py-0.1.36 → http2py-0.1.37}/tests/test_cli_surface.py +0 -0
  35. {http2py-0.1.36 → http2py-0.1.37}/tests/test_error_statuses.py +0 -0
  36. {http2py-0.1.36 → http2py-0.1.37}/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.37
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,9 +79,9 @@ _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
87
  SETUP_PY = """from setuptools import setup
@@ -158,11 +158,7 @@ def object_ref(obj: Union[str, Callable]) -> str:
158
158
  def _route_names(api: HttpClient) -> list:
159
159
  """The method names ``api`` registered, each a plain identifier, since each is
160
160
  written into generated source."""
161
- names = [
162
- name
163
- for name, method in vars(api).items()
164
- if getattr(method, "method_spec", None)
165
- ]
161
+ names = list(api.routes)
166
162
  for name in names:
167
163
  if not _is_plain_identifier(name):
168
164
  raise ValueError(
@@ -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,6 +111,7 @@ 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():
@@ -149,7 +165,39 @@ class HttpClient:
149
165
  func.method_spec = method_spec
150
166
  func.content_type = content_type
151
167
  funcname = func.__name__
152
- setattr(self, funcname, func.__get__(self))
168
+ method = func.__get__(self)
169
+ if "routes" not in vars(self): # e.g. a subclass that skips HttpClient.__init__
170
+ self.routes = {}
171
+ if funcname in self.routes:
172
+ warnings.warn(
173
+ f"Two routes are named {funcname!r} ({url_template!r} replaces the "
174
+ "earlier one): give each its own x-method_name.",
175
+ stacklevel=3,
176
+ )
177
+ can_be_attribute = self._can_be_route_attribute(funcname)
178
+ self.routes[funcname] = method
179
+ if can_be_attribute:
180
+ setattr(self, funcname, method)
181
+ else:
182
+ warnings.warn(
183
+ f"Route {funcname!r} is not set as an attribute of the client: the name "
184
+ f"starts with '_' or is already an attribute of {type(self).__name__}. "
185
+ f"Call it as client.routes[{funcname!r}](...).",
186
+ stacklevel=3,
187
+ )
188
+
189
+ def _can_be_route_attribute(self, name):
190
+ """Whether a route called ``name`` may be set as an attribute of the client:
191
+ the name is public, and is not an attribute the client works with already
192
+ (an earlier route of the same name, or a ``_route_shadowable_attrs`` one, can
193
+ be replaced)."""
194
+ if name.startswith("_"):
195
+ return False
196
+ if name in self.routes and vars(self).get(name) is self.routes[name]:
197
+ return True
198
+ if name in self._route_shadowable_attrs:
199
+ return True
200
+ return not (hasattr(type(self), name) or name in vars(self))
153
201
 
154
202
  def handle_request(self, method, url, **_request_kwargs):
155
203
  self.ensure_login()
@@ -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.37"
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,106 @@
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
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