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.
- {http2py-0.1.36 → http2py-0.1.37}/PKG-INFO +1 -1
- {http2py-0.1.36 → http2py-0.1.37}/http2py/api_pkg_maker.py +3 -7
- {http2py-0.1.36 → http2py-0.1.37}/http2py/cli_maker.py +2 -3
- {http2py-0.1.36 → http2py-0.1.37}/http2py/client.py +49 -1
- {http2py-0.1.36 → http2py-0.1.37}/pyproject.toml +1 -1
- {http2py-0.1.36 → http2py-0.1.37}/tests/test_generated_clients.py +1 -0
- http2py-0.1.37/tests/test_route_namespace.py +106 -0
- {http2py-0.1.36 → http2py-0.1.37}/.gitattributes +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/.github/workflows/ci.yml +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/.gitignore +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/LICENSE +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/README.md +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/conftest.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/http2py/__init__.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/http2py/authentication.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/http2py/constants.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/http2py/decorators.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/http2py/default_configs.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/http2py/example_cli.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/http2py/global_state.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/http2py/http2py_cls.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/http2py/py2request.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/http2py/resources/module_maker/func_tpl.txt +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/http2py/testing_utils.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/http2py/tests/__init__.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/http2py/tests/api_pkg_maker_test.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/http2py/tests/conftest.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/http2py/tests/test_requirements.txt +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/http2py/util.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/tests/cli_goldens/mk_cli.json +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/tests/spec_fixture.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/tests/test_binary_payloads.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/tests/test_ci_collection_contract.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/tests/test_cli_surface.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/tests/test_error_statuses.py +0 -0
- {http2py-0.1.36 → http2py-0.1.37}/tests/test_smoke.py +0 -0
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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()
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|