http2py 0.1.35__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.35 → http2py-0.1.37}/PKG-INFO +1 -1
- http2py-0.1.37/http2py/api_pkg_maker.py +356 -0
- {http2py-0.1.35 → http2py-0.1.37}/http2py/cli_maker.py +22 -5
- {http2py-0.1.35 → http2py-0.1.37}/http2py/client.py +49 -1
- {http2py-0.1.35 → http2py-0.1.37}/http2py/decorators.py +2 -0
- {http2py-0.1.35 → http2py-0.1.37}/http2py/tests/api_pkg_maker_test.py +4 -2
- {http2py-0.1.35 → http2py-0.1.37}/pyproject.toml +1 -1
- http2py-0.1.37/tests/test_error_statuses.py +25 -0
- http2py-0.1.37/tests/test_generated_clients.py +185 -0
- http2py-0.1.37/tests/test_route_namespace.py +106 -0
- http2py-0.1.35/http2py/api_pkg_maker.py +0 -219
- {http2py-0.1.35 → http2py-0.1.37}/.gitattributes +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/.github/workflows/ci.yml +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/.gitignore +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/LICENSE +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/README.md +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/conftest.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/http2py/__init__.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/http2py/authentication.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/http2py/constants.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/http2py/default_configs.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/http2py/example_cli.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/http2py/global_state.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/http2py/http2py_cls.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/http2py/py2request.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/http2py/resources/module_maker/func_tpl.txt +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/http2py/testing_utils.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/http2py/tests/__init__.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/http2py/tests/conftest.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/http2py/tests/test_requirements.txt +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/http2py/util.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/tests/cli_goldens/mk_cli.json +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/tests/spec_fixture.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/tests/test_binary_payloads.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/tests/test_ci_collection_contract.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/tests/test_cli_surface.py +0 -0
- {http2py-0.1.35 → http2py-0.1.37}/tests/test_smoke.py +0 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Generate an installable python package from an OpenAPI specification.
|
|
2
|
+
|
|
3
|
+
Given a spec (or the URL of one), :func:`mk_api_pkg` writes a small source
|
|
4
|
+
distribution whose functions are bound to the service's routes.
|
|
5
|
+
|
|
6
|
+
The sdist is built by running ``setup.py sdist`` in a subprocess. This module
|
|
7
|
+
used to call ``setuptools.sandbox.run_setup``, which modern setuptools no longer
|
|
8
|
+
ships -- so merely *importing* it raised ``ImportError`` and the
|
|
9
|
+
``api-pkg-maker`` console script could not start at all (i2mint/http2py#14).
|
|
10
|
+
The subprocess is also better behaved: ``sandbox.run_setup`` needed the process
|
|
11
|
+
to ``os.chdir`` into the build directory and never came back.
|
|
12
|
+
|
|
13
|
+
``setuptools`` is still needed to *run* :func:`mk_api_pkg`, but it is
|
|
14
|
+
deliberately NOT a declared dependency of http2py: it is needed by this one
|
|
15
|
+
function, not by ``import http2py``, and a virtualenv created by ``uv`` does not
|
|
16
|
+
ship it. :func:`_check_build_backend` turns its absence into an actionable
|
|
17
|
+
message instead of a subprocess exit code.
|
|
18
|
+
|
|
19
|
+
Nothing taken from the spec is pasted into generated source as code: the spec
|
|
20
|
+
is written as a JSON data file the package loads at import, and every route
|
|
21
|
+
name must be a plain Python identifier (i2mint/http2py#21). A spec fetched from
|
|
22
|
+
a server therefore cannot inject code into the package built from it.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import keyword
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import shutil
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import tempfile
|
|
33
|
+
import warnings
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
from importlib import import_module
|
|
36
|
+
from typing import Optional, Union
|
|
37
|
+
from http2py.client import HttpClient
|
|
38
|
+
from datetime import datetime, timezone
|
|
39
|
+
|
|
40
|
+
#: Where built packages are written. ``expanduser`` rather than
|
|
41
|
+
#: ``os.environ["HOME"]``: the latter is a module-import-time ``KeyError`` on
|
|
42
|
+
#: Windows, which would take the whole package down with it.
|
|
43
|
+
OUTPUT_DIR = os.path.join(os.path.expanduser("~"), "http2py", "api_pkgs")
|
|
44
|
+
|
|
45
|
+
INIT_FILE_TPL = """from .funcs import {funcs}
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
SPEC_FILENAME = "openapi_spec.json"
|
|
49
|
+
|
|
50
|
+
# Every name the generated module defines besides the routes starts with "_",
|
|
51
|
+
# except ``api``; route names may not start with "_" nor be "api" (_route_names).
|
|
52
|
+
FUNCS_FILE_TPL = """import json as _json
|
|
53
|
+
from importlib import import_module as _import_module
|
|
54
|
+
from pathlib import Path as _Path
|
|
55
|
+
|
|
56
|
+
from i2 import Sig as _Sig
|
|
57
|
+
from http2py.client import HttpClient as _HttpClient
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _import_object(ref):
|
|
61
|
+
module_name, _, name = ref.rpartition(".")
|
|
62
|
+
return getattr(_import_module(module_name), name)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_SPEC = _json.loads(
|
|
66
|
+
(_Path(__file__).parent / {spec_filename!r}).read_text(encoding="utf-8")
|
|
67
|
+
)
|
|
68
|
+
api = _HttpClient(openapi_spec=_SPEC, binary_loads={binary_loads})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _apply_sig(meth):
|
|
72
|
+
def wrapper(func):
|
|
73
|
+
sig = _Sig(meth) - 'self'
|
|
74
|
+
return sig(func)
|
|
75
|
+
return wrapper
|
|
76
|
+
{funcs}"""
|
|
77
|
+
|
|
78
|
+
_RESERVED_ROUTE_NAMES = {"api", "funcs"} # funcs: the module's own name
|
|
79
|
+
|
|
80
|
+
FUNC_TPL = """
|
|
81
|
+
|
|
82
|
+
@_apply_sig(api.routes[{func_name!r}])
|
|
83
|
+
def {func_name}(*args, **kwargs):
|
|
84
|
+
return api.routes[{func_name!r}](*args, **kwargs)
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
SETUP_PY = """from setuptools import setup
|
|
88
|
+
|
|
89
|
+
setup()
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
SETUP_CFG_TPL = """[metadata]
|
|
93
|
+
name = {name}
|
|
94
|
+
version = {version}
|
|
95
|
+
platforms = any
|
|
96
|
+
description = {description}
|
|
97
|
+
author = {author}
|
|
98
|
+
license = {license}
|
|
99
|
+
|
|
100
|
+
[options]
|
|
101
|
+
packages = find:
|
|
102
|
+
include_package_data = True
|
|
103
|
+
zip_safe = False
|
|
104
|
+
install_requires =
|
|
105
|
+
i2
|
|
106
|
+
http2py
|
|
107
|
+
|
|
108
|
+
[options.package_data]
|
|
109
|
+
* = {spec_filename}
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
_IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*" # ASCII: Python normalizes other letters
|
|
113
|
+
_DOTTED_PATH = re.compile(rf"{_IDENTIFIER}(\.{_IDENTIFIER})+")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _is_plain_identifier(name: str) -> bool:
|
|
117
|
+
return re.fullmatch(_IDENTIFIER, name) is not None and not keyword.iskeyword(name)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def object_ref(obj: Union[str, Callable]) -> str:
|
|
121
|
+
"""The dotted import path (``"module.name"``) a generated package uses to
|
|
122
|
+
import ``obj``: ``obj`` itself when it is a string (checked to name a callable,
|
|
123
|
+
unless its module is not installed here: then a warning), else the path of an
|
|
124
|
+
importable callable, checked to import back to that very object.
|
|
125
|
+
|
|
126
|
+
>>> object_ref(bytes)
|
|
127
|
+
'builtins.bytes'
|
|
128
|
+
>>> object_ref("http2py.decorators.unsafe_pickle_loads")
|
|
129
|
+
'http2py.decorators.unsafe_pickle_loads'
|
|
130
|
+
>>> object_ref(lambda b: b) # doctest: +ELLIPSIS
|
|
131
|
+
Traceback (most recent call last):
|
|
132
|
+
...
|
|
133
|
+
ValueError: Not an importable module.name path: '...<lambda>'
|
|
134
|
+
"""
|
|
135
|
+
if isinstance(obj, str):
|
|
136
|
+
ref = obj
|
|
137
|
+
else:
|
|
138
|
+
ref = f"{getattr(obj, '__module__', '')}.{getattr(obj, '__qualname__', '')}"
|
|
139
|
+
if not _DOTTED_PATH.fullmatch(ref):
|
|
140
|
+
raise ValueError(f"Not an importable module.name path: {ref[:80]!r}")
|
|
141
|
+
module_name, _, name = ref.rpartition(".")
|
|
142
|
+
try:
|
|
143
|
+
imported = getattr(import_module(module_name), name)
|
|
144
|
+
except ModuleNotFoundError as error:
|
|
145
|
+
if isinstance(obj, str): # may be installed only where the package runs
|
|
146
|
+
warnings.warn(f"{ref!r} cannot be imported here ({error}); kept as given")
|
|
147
|
+
return ref
|
|
148
|
+
raise ValueError(f"{ref!r} does not import: {error}") from None
|
|
149
|
+
except Exception as error:
|
|
150
|
+
raise ValueError(f"{ref!r} does not import: {error!r}") from None
|
|
151
|
+
if not callable(imported):
|
|
152
|
+
raise ValueError(f"{ref!r} is not callable")
|
|
153
|
+
if not isinstance(obj, str) and imported is not obj:
|
|
154
|
+
raise ValueError(f"{ref!r} does not import back to {obj!r}")
|
|
155
|
+
return ref
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _route_names(api: HttpClient) -> list:
|
|
159
|
+
"""The method names ``api`` registered, each a plain identifier, since each is
|
|
160
|
+
written into generated source."""
|
|
161
|
+
names = list(api.routes)
|
|
162
|
+
for name in names:
|
|
163
|
+
if not _is_plain_identifier(name):
|
|
164
|
+
raise ValueError(
|
|
165
|
+
f"Route name {name[:60]!r} is not an ASCII Python identifier: cannot "
|
|
166
|
+
"generate a function for it."
|
|
167
|
+
)
|
|
168
|
+
if name.startswith("_") or name in _RESERVED_ROUTE_NAMES:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"Route name {name!r} is reserved (it starts with '_' or is 'api')."
|
|
171
|
+
)
|
|
172
|
+
if not names:
|
|
173
|
+
raise RuntimeError("The API is empty")
|
|
174
|
+
return names
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _cfg_value(text: str) -> str:
|
|
178
|
+
"""``text`` as a setup.cfg value: one line, ``%`` escaped from interpolation."""
|
|
179
|
+
return " ".join(str(text).split()).replace("%", "%%")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def api_pkg_files(
|
|
183
|
+
openapi_spec: dict = None,
|
|
184
|
+
openapi_url: str = None,
|
|
185
|
+
pkg_name: str = None,
|
|
186
|
+
pkg_version: str = None,
|
|
187
|
+
pkg_description: str = None,
|
|
188
|
+
pkg_author: str = None,
|
|
189
|
+
pkg_license: str = None,
|
|
190
|
+
*,
|
|
191
|
+
binary_loads: Optional[Union[str, Callable]] = None,
|
|
192
|
+
) -> dict:
|
|
193
|
+
"""The source files of the package :func:`mk_api_pkg` builds, as
|
|
194
|
+
``{relative path: text}`` (see :func:`mk_api_pkg` for the arguments)."""
|
|
195
|
+
if isinstance(openapi_spec, str): # e.g. given as JSON text on the command line
|
|
196
|
+
openapi_spec = json.loads(openapi_spec)
|
|
197
|
+
api = HttpClient(openapi_spec=openapi_spec, url=openapi_url)
|
|
198
|
+
func_names = _route_names(api)
|
|
199
|
+
loads_code = (
|
|
200
|
+
"None"
|
|
201
|
+
if binary_loads is None
|
|
202
|
+
else (f"_import_object({object_ref(binary_loads)!r})")
|
|
203
|
+
)
|
|
204
|
+
pkg_name = pkg_name or _mk_pkg_name()
|
|
205
|
+
if not _is_plain_identifier(pkg_name):
|
|
206
|
+
raise ValueError(f"pkg_name must be an ASCII Python identifier: {pkg_name!r}")
|
|
207
|
+
server_url = api.openapi_spec["servers"][0]["url"]
|
|
208
|
+
setup_cfg = SETUP_CFG_TPL.format(
|
|
209
|
+
name=pkg_name,
|
|
210
|
+
version=_cfg_value(pkg_version or "1.0.0"),
|
|
211
|
+
description=_cfg_value(
|
|
212
|
+
pkg_description
|
|
213
|
+
or f"A client API to consume the webservices exposed at {server_url}"
|
|
214
|
+
),
|
|
215
|
+
author=_cfg_value(pkg_author or "API Package Maker"),
|
|
216
|
+
license=_cfg_value(pkg_license or "Apache"),
|
|
217
|
+
spec_filename=SPEC_FILENAME,
|
|
218
|
+
)
|
|
219
|
+
funcs_code = FUNCS_FILE_TPL.format(
|
|
220
|
+
spec_filename=SPEC_FILENAME,
|
|
221
|
+
binary_loads=loads_code,
|
|
222
|
+
funcs="".join(FUNC_TPL.format(func_name=name) for name in func_names),
|
|
223
|
+
)
|
|
224
|
+
return {
|
|
225
|
+
"setup.py": SETUP_PY,
|
|
226
|
+
"setup.cfg": setup_cfg,
|
|
227
|
+
f"{pkg_name}/__init__.py": INIT_FILE_TPL.format(funcs=", ".join(func_names)),
|
|
228
|
+
f"{pkg_name}/funcs.py": funcs_code,
|
|
229
|
+
f"{pkg_name}/{SPEC_FILENAME}": json.dumps(api.openapi_spec, indent=1),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _mk_pkg_name():
|
|
234
|
+
return f"apipkg{int(datetime.now(timezone.utc).timestamp())}"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def mk_api_pkg(
|
|
238
|
+
openapi_spec: dict = None,
|
|
239
|
+
openapi_url: str = None,
|
|
240
|
+
pkg_name: str = None,
|
|
241
|
+
pkg_version: str = None,
|
|
242
|
+
pkg_description: str = None,
|
|
243
|
+
pkg_author: str = None,
|
|
244
|
+
pkg_license: str = None,
|
|
245
|
+
*,
|
|
246
|
+
binary_loads: Optional[Union[str, Callable]] = None,
|
|
247
|
+
output_dir: str = None,
|
|
248
|
+
):
|
|
249
|
+
"""
|
|
250
|
+
Build a pip installable package containing the functions to consume a running
|
|
251
|
+
webservice application executed with py2http.
|
|
252
|
+
|
|
253
|
+
The package holds the spec as it was when built (fetched from ``openapi_url``
|
|
254
|
+
if no ``openapi_spec`` is given), as a JSON file it loads at import.
|
|
255
|
+
|
|
256
|
+
:param openapi_spec: The openapi specification for the webservice.
|
|
257
|
+
:type openapi_spec: dict, optional
|
|
258
|
+
:param openapi_url: The url to fetch the openapi specification for the webservice.
|
|
259
|
+
:type openapi_url: str, optional
|
|
260
|
+
:param pkg_name: The name of the package. If no name is provided, default to
|
|
261
|
+
'apipkg{timestamp}'
|
|
262
|
+
:type pkg_name: str, optional
|
|
263
|
+
:param pkg_version: The version number of the package. If no version is provided,
|
|
264
|
+
default to 1.0.0
|
|
265
|
+
:type pkg_version: str, optional
|
|
266
|
+
:param pkg_description: The description of the package.
|
|
267
|
+
:type pkg_description: str, optional
|
|
268
|
+
:param pkg_author: The author of the package. If no author provided, default to
|
|
269
|
+
'API Package Maker'
|
|
270
|
+
:type pkg_author: str, optional
|
|
271
|
+
:param pkg_license: The license for the package. If no license provided, default
|
|
272
|
+
to 'Apache'
|
|
273
|
+
:type pkg_license: str, optional
|
|
274
|
+
:param binary_loads: How the package decodes binary (octet-stream) responses:
|
|
275
|
+
an importable callable or its dotted path (``"builtins.bytes"`` for the raw
|
|
276
|
+
content, ``"http2py.decorators.unsafe_pickle_loads"`` for a fully trusted
|
|
277
|
+
server). By default they raise ``BinaryResponseNotDecoded``.
|
|
278
|
+
:param output_dir: Where the built ``.tar.gz`` is written (default ``OUTPUT_DIR``).
|
|
279
|
+
"""
|
|
280
|
+
_check_build_backend()
|
|
281
|
+
output_dir = OUTPUT_DIR if output_dir is None else output_dir
|
|
282
|
+
files = api_pkg_files(
|
|
283
|
+
openapi_spec,
|
|
284
|
+
openapi_url,
|
|
285
|
+
pkg_name,
|
|
286
|
+
pkg_version,
|
|
287
|
+
pkg_description,
|
|
288
|
+
pkg_author,
|
|
289
|
+
pkg_license,
|
|
290
|
+
binary_loads=binary_loads,
|
|
291
|
+
)
|
|
292
|
+
pkg_name = next(path.split("/")[0] for path in files if "/" in path)
|
|
293
|
+
|
|
294
|
+
tempdir = tempfile.mkdtemp()
|
|
295
|
+
try:
|
|
296
|
+
for relpath, content in files.items():
|
|
297
|
+
filepath = os.path.join(tempdir, *relpath.split("/"))
|
|
298
|
+
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
|
299
|
+
with open(filepath, "w", encoding="utf-8") as file:
|
|
300
|
+
file.write(content)
|
|
301
|
+
build = subprocess.run(
|
|
302
|
+
[sys.executable, "setup.py", "sdist"],
|
|
303
|
+
cwd=tempdir,
|
|
304
|
+
capture_output=True,
|
|
305
|
+
text=True,
|
|
306
|
+
)
|
|
307
|
+
if build.returncode != 0:
|
|
308
|
+
# check=True would raise a CalledProcessError carrying only the exit
|
|
309
|
+
# status, discarding the captured output that says what went wrong.
|
|
310
|
+
raise RuntimeError(
|
|
311
|
+
f"`setup.py sdist` failed (exit {build.returncode}) while building "
|
|
312
|
+
f"{pkg_name!r}.\n--- stdout ---\n{build.stdout}"
|
|
313
|
+
f"\n--- stderr ---\n{build.stderr}"
|
|
314
|
+
)
|
|
315
|
+
# setuptools normalizes the name and version in the file name it writes
|
|
316
|
+
(pkg_filename,) = os.listdir(os.path.join(tempdir, "dist"))
|
|
317
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
318
|
+
shutil.copy(src=os.path.join(tempdir, "dist", pkg_filename), dst=output_dir)
|
|
319
|
+
|
|
320
|
+
return os.path.join(output_dir, pkg_filename)
|
|
321
|
+
|
|
322
|
+
finally:
|
|
323
|
+
shutil.rmtree(tempdir)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _check_build_backend():
|
|
327
|
+
"""Fail early and legibly when the interpreter cannot build an sdist.
|
|
328
|
+
|
|
329
|
+
``setup.py sdist`` needs setuptools in the *subprocess's* interpreter, which
|
|
330
|
+
is this one. Environments created by ``uv`` (and ``python -m venv
|
|
331
|
+
--without-pip``) do not have it. Without this check the user sees a bare
|
|
332
|
+
``CalledProcessError`` with an exit status and no cause.
|
|
333
|
+
"""
|
|
334
|
+
import importlib.util
|
|
335
|
+
|
|
336
|
+
if importlib.util.find_spec("setuptools") is None:
|
|
337
|
+
raise RuntimeError(
|
|
338
|
+
"mk_api_pkg builds a source distribution with `setup.py sdist`, "
|
|
339
|
+
f"which needs setuptools in {sys.executable}. Install it with: "
|
|
340
|
+
f"{sys.executable} -m pip install setuptools"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def main():
|
|
345
|
+
"""Entry point for the ``api-pkg-maker`` console script; returns the exit code.
|
|
346
|
+
|
|
347
|
+
``cw.dispatch`` returns the code rather than exiting, and
|
|
348
|
+
``[project.scripts]`` wraps this in ``sys.exit(main())``.
|
|
349
|
+
"""
|
|
350
|
+
import cw
|
|
351
|
+
|
|
352
|
+
return cw.dispatch(mk_api_pkg)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
if __name__ == "__main__":
|
|
356
|
+
raise SystemExit(main())
|
|
@@ -24,6 +24,7 @@ import cw
|
|
|
24
24
|
from i2.io_trans import JSONAnnotAndDfltIoTrans
|
|
25
25
|
from i2.signatures import set_signature_of_func, Sig, KO
|
|
26
26
|
from http2py import HttpClient
|
|
27
|
+
from http2py.decorators import check_loads
|
|
27
28
|
from http2py.authentication import mk_auth, DFLT_CONFIG_FILENAME
|
|
28
29
|
|
|
29
30
|
#: How the command line is derived from a signature.
|
|
@@ -87,6 +88,8 @@ def mk_cli(
|
|
|
87
88
|
filename: str = "",
|
|
88
89
|
parse_yaml: bool = False,
|
|
89
90
|
config_filename: str = DFLT_CONFIG_FILENAME,
|
|
91
|
+
*,
|
|
92
|
+
binary_loads: Callable = None,
|
|
90
93
|
):
|
|
91
94
|
"""Creates a CLI parser that exposes all of the methods of an HTTP client defined by an OpenAPI spec.
|
|
92
95
|
Accepts either an OpenAPI spec dict, a url, or path to a local file.
|
|
@@ -97,7 +100,12 @@ def mk_cli(
|
|
|
97
100
|
:param parse_yaml: A flag to indicate that the provided OpenAPI spec is in YAML format rather than JSON (default: False)
|
|
98
101
|
:param config_filename: The path to a local config file that contains authentication details for the HTTP service
|
|
99
102
|
in JSON format (default: ~/.http2cli/credentials.json
|
|
103
|
+
:param binary_loads: How commands decode binary (octet-stream) responses, passed to
|
|
104
|
+
``HttpClient``: ``bytes`` for the raw content, a decoder of your own, or
|
|
105
|
+
``http2py.decorators.unsafe_pickle_loads`` for a fully trusted server. By
|
|
106
|
+
default such a response raises ``BinaryResponseNotDecoded``.
|
|
100
107
|
"""
|
|
108
|
+
check_loads(binary_loads)
|
|
101
109
|
if not openapi_spec:
|
|
102
110
|
if url:
|
|
103
111
|
raw_spec = requests.get(url).text
|
|
@@ -128,9 +136,14 @@ def mk_cli(
|
|
|
128
136
|
expected_auth_kwargs = login_details.get("login_inputs", [])
|
|
129
137
|
client_details = HttpClient(openapi_spec)
|
|
130
138
|
cli_methods = [
|
|
131
|
-
register_cli_method(
|
|
132
|
-
|
|
133
|
-
|
|
139
|
+
register_cli_method(
|
|
140
|
+
openapi_spec,
|
|
141
|
+
method,
|
|
142
|
+
expected_auth_kwargs,
|
|
143
|
+
config_filename,
|
|
144
|
+
binary_loads=binary_loads,
|
|
145
|
+
)
|
|
146
|
+
for method in client_details.routes.values()
|
|
134
147
|
]
|
|
135
148
|
return cw.mk_parser(cli_methods, convention=CLI_CONVENTION)
|
|
136
149
|
|
|
@@ -157,6 +170,8 @@ def register_cli_method(
|
|
|
157
170
|
expected_auth_kwargs: Iterable[str] = None,
|
|
158
171
|
config_filename: str = DFLT_CONFIG_FILENAME,
|
|
159
172
|
profile: str = "",
|
|
173
|
+
*,
|
|
174
|
+
binary_loads: Callable = None,
|
|
160
175
|
):
|
|
161
176
|
"""Creates a CLI-friendly function to instantiate an HttpClient with appropriate authentication
|
|
162
177
|
arguments and call a particular method of the client instance
|
|
@@ -168,6 +183,8 @@ def register_cli_method(
|
|
|
168
183
|
:param config_filename: The path to a JSON file to read for authentication kwargs
|
|
169
184
|
(defaults to ~/.http2py/credentials.json)
|
|
170
185
|
:param profile: The named profile to find in the config file
|
|
186
|
+
:param binary_loads: How binary (octet-stream) responses are decoded (see
|
|
187
|
+
:func:`mk_cli`)
|
|
171
188
|
"""
|
|
172
189
|
methodname = client_method.__name__
|
|
173
190
|
method_sig = Sig(client_method)
|
|
@@ -189,8 +206,8 @@ def register_cli_method(
|
|
|
189
206
|
auth_kwargs = mk_auth(
|
|
190
207
|
auth_kwargs, expected_auth_kwargs, config_filename, profile
|
|
191
208
|
)
|
|
192
|
-
http_client = HttpClient(openapi_spec, **auth_kwargs)
|
|
193
|
-
return
|
|
209
|
+
http_client = HttpClient(openapi_spec, binary_loads=binary_loads, **auth_kwargs)
|
|
210
|
+
return http_client.routes[methodname](**kwargs)
|
|
194
211
|
|
|
195
212
|
method_sig.wrap(cli_method)
|
|
196
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()
|
|
@@ -48,6 +48,8 @@ def _handle_error(resp):
|
|
|
48
48
|
raise InputError(resp.text)
|
|
49
49
|
if resp.reason == "DuplicateRecordError":
|
|
50
50
|
raise DuplicateRecordError(resp.text)
|
|
51
|
+
if resp.status_code == 401: # py2http answers AuthorizationError with 401
|
|
52
|
+
raise AuthorizationError(resp.text)
|
|
51
53
|
if resp.status_code == 403:
|
|
52
54
|
raise ForbiddenError(resp.text)
|
|
53
55
|
if resp.status_code == 404:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import re
|
|
3
3
|
import pytest
|
|
4
|
-
from http2py.api_pkg_maker import
|
|
4
|
+
from http2py.api_pkg_maker import mk_api_pkg
|
|
5
5
|
from http2py.constants import JSON_CONTENT_TYPE
|
|
6
6
|
|
|
7
7
|
|
|
@@ -131,6 +131,7 @@ from http2py.constants import JSON_CONTENT_TYPE
|
|
|
131
131
|
],
|
|
132
132
|
)
|
|
133
133
|
def test_mk_api_pkg(
|
|
134
|
+
tmp_path,
|
|
134
135
|
openapi_spec,
|
|
135
136
|
openapi_url,
|
|
136
137
|
pkg_name,
|
|
@@ -147,9 +148,10 @@ def test_mk_api_pkg(
|
|
|
147
148
|
pkg_description,
|
|
148
149
|
pkg_author,
|
|
149
150
|
pkg_license,
|
|
151
|
+
output_dir=str(tmp_path),
|
|
150
152
|
)
|
|
151
153
|
output_pkg_dirpath = os.path.dirname(output_pkg_filepath)
|
|
152
|
-
assert output_pkg_dirpath ==
|
|
154
|
+
assert output_pkg_dirpath == str(tmp_path)
|
|
153
155
|
output_pkg_filename = os.path.basename(output_pkg_filepath)
|
|
154
156
|
output_pkg_name = re.search(r"(.+?)-", output_pkg_filename).group(1)
|
|
155
157
|
if pkg_name:
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Server errors come back as the i2 error the server raised."""
|
|
2
|
+
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from i2.errors import AuthorizationError, ForbiddenError, InputError, NotFoundError
|
|
7
|
+
|
|
8
|
+
from http2py.decorators import _handle_error
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize(
|
|
12
|
+
"status, reason, error",
|
|
13
|
+
[
|
|
14
|
+
(401, "Unauthorized", AuthorizationError), # py2http since i2mint/py2http#31
|
|
15
|
+
(400, "AuthorizationError", AuthorizationError), # older py2http
|
|
16
|
+
(400, "InputError", InputError),
|
|
17
|
+
(403, "Forbidden", ForbiddenError),
|
|
18
|
+
(404, "Not Found", NotFoundError),
|
|
19
|
+
(500, "Internal Server Error", RuntimeError),
|
|
20
|
+
],
|
|
21
|
+
)
|
|
22
|
+
def test_status_maps_to_error(status, reason, error):
|
|
23
|
+
resp = SimpleNamespace(status_code=status, reason=reason, text="msg")
|
|
24
|
+
with pytest.raises(error):
|
|
25
|
+
_handle_error(resp)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""``mk_cli`` and ``api_pkg_maker`` pass ``binary_loads`` through, and a package
|
|
2
|
+
generated from a spec compiles and cannot be made to run code by that spec
|
|
3
|
+
(i2mint/http2py#21)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import copy
|
|
8
|
+
import importlib
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
import tarfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
17
|
+
|
|
18
|
+
from spec_fixture import SPEC # noqa: E402
|
|
19
|
+
|
|
20
|
+
from http2py.api_pkg_maker import api_pkg_files, mk_api_pkg, object_ref # noqa: E402
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _write(files: dict, root: Path) -> None:
|
|
24
|
+
for relpath, content in files.items():
|
|
25
|
+
path = root / relpath
|
|
26
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
path.write_text(content, encoding="utf-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def import_generated(tmp_path, monkeypatch):
|
|
32
|
+
"""Write a generated package under tmp_path and import it."""
|
|
33
|
+
|
|
34
|
+
def _import(files):
|
|
35
|
+
_write(files, tmp_path)
|
|
36
|
+
monkeypatch.syspath_prepend(str(tmp_path))
|
|
37
|
+
pkg_name = next(path.split("/")[0] for path in files if "/" in path)
|
|
38
|
+
importlib.invalidate_caches()
|
|
39
|
+
module = importlib.import_module(f"{pkg_name}.funcs")
|
|
40
|
+
monkeypatch.delitem(sys.modules, pkg_name)
|
|
41
|
+
monkeypatch.delitem(sys.modules, f"{pkg_name}.funcs")
|
|
42
|
+
return module
|
|
43
|
+
|
|
44
|
+
return _import
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_generated_package_compiles_and_exposes_every_route(import_generated):
|
|
48
|
+
files = api_pkg_files(SPEC, pkg_name="genpkg_routes")
|
|
49
|
+
for relpath, content in files.items():
|
|
50
|
+
if relpath.endswith(".py"):
|
|
51
|
+
compile(content, relpath, "exec")
|
|
52
|
+
funcs = import_generated(files)
|
|
53
|
+
assert {"get_thing", "make", "plain"} <= set(vars(funcs))
|
|
54
|
+
assert funcs.api.binary_loads is None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_spec_is_data_not_source():
|
|
58
|
+
spec = copy.deepcopy(SPEC)
|
|
59
|
+
spec["info"]["title"] = "'''\"\"\"); import os; os.system('x') #"
|
|
60
|
+
files = api_pkg_files(spec, pkg_name="genpkg_data")
|
|
61
|
+
assert "os.system" not in files["genpkg_data/funcs.py"]
|
|
62
|
+
assert json.loads(files["genpkg_data/openapi_spec.json"]) == spec
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.parametrize(
|
|
66
|
+
"bad_name",
|
|
67
|
+
[
|
|
68
|
+
"x():\n import os\n os.system('x')\ndef y",
|
|
69
|
+
"class",
|
|
70
|
+
"a-b",
|
|
71
|
+
"\uff41\uff50\uff49", # full-width "api": an identifier Python normalizes to api
|
|
72
|
+
"api",
|
|
73
|
+
"_apply_sig",
|
|
74
|
+
"funcs",
|
|
75
|
+
],
|
|
76
|
+
)
|
|
77
|
+
def test_route_name_that_is_not_an_identifier_is_refused(bad_name):
|
|
78
|
+
spec = copy.deepcopy(SPEC)
|
|
79
|
+
spec["paths"]["/plain"]["get"]["x-method_name"] = bad_name
|
|
80
|
+
with pytest.raises(ValueError, match="not an ASCII Python identifier|reserved"):
|
|
81
|
+
api_pkg_files(spec, pkg_name="genpkg_bad")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_setup_cfg_fields_stay_on_one_line():
|
|
85
|
+
files = api_pkg_files(
|
|
86
|
+
SPEC, pkg_name="genpkg_cfg", pkg_description="one\n[options]\nscripts = x"
|
|
87
|
+
)
|
|
88
|
+
assert "\n[options]\nscripts" not in files["setup.cfg"]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_binary_loads_reaches_the_generated_client(import_generated):
|
|
92
|
+
files = api_pkg_files(SPEC, pkg_name="genpkg_loads", binary_loads=bytes)
|
|
93
|
+
assert import_generated(files).api.binary_loads is bytes
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.parametrize(
|
|
97
|
+
"ref",
|
|
98
|
+
[
|
|
99
|
+
lambda b: b,
|
|
100
|
+
"bytes",
|
|
101
|
+
"os.system('x') or bytes",
|
|
102
|
+
"a.b; import os",
|
|
103
|
+
"json.no_such_loads",
|
|
104
|
+
"json.decoder", # a module, not a callable
|
|
105
|
+
],
|
|
106
|
+
)
|
|
107
|
+
def test_binary_loads_must_be_an_importable_path(ref):
|
|
108
|
+
with pytest.raises(ValueError):
|
|
109
|
+
object_ref(ref)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_mk_api_pkg_ships_the_spec(tmp_path):
|
|
113
|
+
pytest.importorskip("setuptools")
|
|
114
|
+
sdist = mk_api_pkg(SPEC, pkg_name="genpkg_sdist", output_dir=str(tmp_path))
|
|
115
|
+
with tarfile.open(sdist) as tar:
|
|
116
|
+
names = tar.getnames()
|
|
117
|
+
assert "genpkg_sdist-1.0.0/genpkg_sdist/openapi_spec.json" in names
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_dispatch_cli_passes_binary_loads_to_the_client(monkeypatch, tmp_path):
|
|
121
|
+
from http2py import cli_maker
|
|
122
|
+
|
|
123
|
+
seen = {}
|
|
124
|
+
|
|
125
|
+
class FakeClient:
|
|
126
|
+
def __init__(self, openapi_spec, **kwargs):
|
|
127
|
+
seen.update(kwargs)
|
|
128
|
+
self.routes = {"plain": self.plain}
|
|
129
|
+
|
|
130
|
+
def plain(self, **kwargs):
|
|
131
|
+
return "ran"
|
|
132
|
+
|
|
133
|
+
real_mk_cli = cli_maker.mk_cli
|
|
134
|
+
|
|
135
|
+
def mk_cli_then_fake_client(*args, **kwargs):
|
|
136
|
+
parser = real_mk_cli(*args, **kwargs) # built from the real client
|
|
137
|
+
monkeypatch.setattr(cli_maker, "HttpClient", FakeClient) # run with the fake
|
|
138
|
+
return parser
|
|
139
|
+
|
|
140
|
+
monkeypatch.setattr(cli_maker, "mk_cli", mk_cli_then_fake_client)
|
|
141
|
+
monkeypatch.setattr(sys, "argv", ["PROG", "plain", "--api-key", "k"])
|
|
142
|
+
config = tmp_path / "credentials.json"
|
|
143
|
+
config.write_text("{}")
|
|
144
|
+
cli_maker.dispatch_cli(
|
|
145
|
+
openapi_spec=SPEC, config_filename=str(config), binary_loads=bytes
|
|
146
|
+
)
|
|
147
|
+
assert seen["binary_loads"] is bytes
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_mk_cli_refuses_a_non_callable_binary_loads():
|
|
151
|
+
from http2py.cli_maker import mk_cli
|
|
152
|
+
|
|
153
|
+
with pytest.raises(TypeError):
|
|
154
|
+
mk_cli(openapi_spec=SPEC, binary_loads="bytes")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_spec_without_routes_is_refused():
|
|
158
|
+
spec = copy.deepcopy(SPEC)
|
|
159
|
+
spec["paths"] = {"/x": {}}
|
|
160
|
+
with pytest.raises(RuntimeError, match="empty"):
|
|
161
|
+
api_pkg_files(spec, pkg_name="genpkg_empty")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_spec_may_be_given_as_json_text(import_generated):
|
|
165
|
+
files = api_pkg_files(json.dumps(SPEC), pkg_name="genpkg_text")
|
|
166
|
+
assert "plain" in vars(import_generated(files))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_awkward_names_and_values_still_build(tmp_path):
|
|
170
|
+
pytest.importorskip("setuptools")
|
|
171
|
+
spec = copy.deepcopy(SPEC)
|
|
172
|
+
spec["servers"] = [{"url": "https://h/a%20b"}]
|
|
173
|
+
sdist = mk_api_pkg(
|
|
174
|
+
spec,
|
|
175
|
+
pkg_name="MyPkg",
|
|
176
|
+
pkg_version="v1.0",
|
|
177
|
+
pkg_author="100% me",
|
|
178
|
+
output_dir=str(tmp_path),
|
|
179
|
+
)
|
|
180
|
+
assert Path(sdist).is_file()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_binary_loads_ref_to_a_module_not_installed_here_is_kept():
|
|
184
|
+
with pytest.warns(UserWarning, match="cannot be imported here"):
|
|
185
|
+
assert object_ref("no_such_module.loads") == "no_such_module.loads"
|
|
@@ -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
|
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
"""Generate an installable python package from an OpenAPI specification.
|
|
2
|
-
|
|
3
|
-
Given a spec (or the URL of one), :func:`mk_api_pkg` writes a small source
|
|
4
|
-
distribution whose functions are bound to the service's routes.
|
|
5
|
-
|
|
6
|
-
The sdist is built by running ``setup.py sdist`` in a subprocess. This module
|
|
7
|
-
used to call ``setuptools.sandbox.run_setup``, which modern setuptools no longer
|
|
8
|
-
ships -- so merely *importing* it raised ``ImportError`` and the
|
|
9
|
-
``api-pkg-maker`` console script could not start at all (i2mint/http2py#14).
|
|
10
|
-
The subprocess is also better behaved: ``sandbox.run_setup`` needed the process
|
|
11
|
-
to ``os.chdir`` into the build directory and never came back.
|
|
12
|
-
|
|
13
|
-
``setuptools`` is still needed to *run* :func:`mk_api_pkg`, but it is
|
|
14
|
-
deliberately NOT a declared dependency of http2py: it is needed by this one
|
|
15
|
-
function, not by ``import http2py``, and a virtualenv created by ``uv`` does not
|
|
16
|
-
ship it. :func:`_check_build_backend` turns its absence into an actionable
|
|
17
|
-
message instead of a subprocess exit code.
|
|
18
|
-
"""
|
|
19
|
-
|
|
20
|
-
import os
|
|
21
|
-
import shutil
|
|
22
|
-
import subprocess
|
|
23
|
-
import sys
|
|
24
|
-
import tempfile
|
|
25
|
-
from http2py.client import HttpClient
|
|
26
|
-
from datetime import datetime, timezone
|
|
27
|
-
|
|
28
|
-
#: Where built packages are written. ``expanduser`` rather than
|
|
29
|
-
#: ``os.environ["HOME"]``: the latter is a module-import-time ``KeyError`` on
|
|
30
|
-
#: Windows, which would take the whole package down with it.
|
|
31
|
-
OUTPUT_DIR = os.path.join(os.path.expanduser("~"), "http2py", "api_pkgs")
|
|
32
|
-
|
|
33
|
-
INIT_FILE_TPL = """from .funcs import {funcs}
|
|
34
|
-
"""
|
|
35
|
-
|
|
36
|
-
FUNCS_FILE_TPL = """from i2 import Sig
|
|
37
|
-
from http2py.client import HttpClient
|
|
38
|
-
|
|
39
|
-
api = HttpClient(openapi_spec={openapi_spec}, url={openapi_url})
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def apply_sig(meth):
|
|
43
|
-
def wrapper(func):
|
|
44
|
-
sig = Sig(meth) - 'self'
|
|
45
|
-
return sig(func)
|
|
46
|
-
return wrapper
|
|
47
|
-
{funcs}"""
|
|
48
|
-
|
|
49
|
-
FUNC_TPL = """
|
|
50
|
-
|
|
51
|
-
@apply_sig(api.{func_name})
|
|
52
|
-
def {func_name}(*args, **kwargs):
|
|
53
|
-
return api.{func_name}(*args, **kwargs)
|
|
54
|
-
"""
|
|
55
|
-
|
|
56
|
-
SETUP_PY = """from setuptools import setup
|
|
57
|
-
|
|
58
|
-
setup()
|
|
59
|
-
"""
|
|
60
|
-
|
|
61
|
-
SETUP_CFG_TPL = """[metadata]
|
|
62
|
-
name = {name}
|
|
63
|
-
version = {version}
|
|
64
|
-
platforms = any
|
|
65
|
-
description = {description}
|
|
66
|
-
author = {author}
|
|
67
|
-
license = {license}
|
|
68
|
-
|
|
69
|
-
[options]
|
|
70
|
-
packages = find:
|
|
71
|
-
include_package_data = True
|
|
72
|
-
zip_safe = False
|
|
73
|
-
install_requires =
|
|
74
|
-
i2
|
|
75
|
-
http2py
|
|
76
|
-
"""
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def mk_api_pkg(
|
|
80
|
-
openapi_spec: dict = None,
|
|
81
|
-
openapi_url: str = None,
|
|
82
|
-
pkg_name: str = None,
|
|
83
|
-
pkg_version: str = None,
|
|
84
|
-
pkg_description: str = None,
|
|
85
|
-
pkg_author: str = None,
|
|
86
|
-
pkg_license: str = None,
|
|
87
|
-
):
|
|
88
|
-
"""
|
|
89
|
-
Build a pip installable package containing the functions to consume a running
|
|
90
|
-
webservice application executed with py2http.
|
|
91
|
-
|
|
92
|
-
:param openapi_spec: The openapi specification for the webservice.
|
|
93
|
-
:type openapi_spec: dict, optional
|
|
94
|
-
:param openapi_url: The url to fetch the openapi specification for the webservice.
|
|
95
|
-
:type openapi_url: str, optional
|
|
96
|
-
:param pkg_name: The name of the package. If no name is provided, default to
|
|
97
|
-
'apipkg{timestamp}'
|
|
98
|
-
:type pkg_name: str, optional
|
|
99
|
-
:param pkg_version: The version number of the package. If no version is provided,
|
|
100
|
-
default to 1.0.0
|
|
101
|
-
:type pkg_version: str, optional
|
|
102
|
-
:param pkg_description: The description of the package.
|
|
103
|
-
:type pkg_description: str, optional
|
|
104
|
-
:param pkg_author: The author of the package. If no author provided, default to
|
|
105
|
-
'API Package Maker'
|
|
106
|
-
:type pkg_author: str, optional
|
|
107
|
-
:param pkg_license: The license for the package. If no license provided, default
|
|
108
|
-
to 'Apache'
|
|
109
|
-
:type pkg_license: str, optional
|
|
110
|
-
"""
|
|
111
|
-
|
|
112
|
-
def mk_str_element(value: str):
|
|
113
|
-
return value if value is None else f"'{value}'"
|
|
114
|
-
|
|
115
|
-
def mk_pkg_name():
|
|
116
|
-
dt = datetime.now(timezone.utc)
|
|
117
|
-
utc_time = dt.replace(tzinfo=timezone.utc)
|
|
118
|
-
utc_timestamp = int(utc_time.timestamp())
|
|
119
|
-
return f"apipkg{utc_timestamp}"
|
|
120
|
-
|
|
121
|
-
def create_file(filepath, content):
|
|
122
|
-
with open(filepath, "w") as file:
|
|
123
|
-
print(content, file=file)
|
|
124
|
-
|
|
125
|
-
_check_build_backend()
|
|
126
|
-
|
|
127
|
-
tempdir = tempfile.mkdtemp()
|
|
128
|
-
try:
|
|
129
|
-
api = HttpClient(openapi_spec=openapi_spec, url=openapi_url)
|
|
130
|
-
paths_spec = api.openapi_spec.get("paths")
|
|
131
|
-
if not paths_spec:
|
|
132
|
-
raise RuntimeError("The API is empty")
|
|
133
|
-
func_names = [path[1:] for path in paths_spec]
|
|
134
|
-
funcs_code = [FUNC_TPL.format(func_name=func_name) for func_name in func_names]
|
|
135
|
-
funcs_file_code = FUNCS_FILE_TPL.format(
|
|
136
|
-
openapi_spec=mk_str_element(openapi_spec),
|
|
137
|
-
openapi_url=mk_str_element(openapi_url),
|
|
138
|
-
funcs="".join(funcs_code),
|
|
139
|
-
)
|
|
140
|
-
init_file_code = INIT_FILE_TPL.format(funcs=", ".join(func_names))
|
|
141
|
-
pkg_name = pkg_name or mk_pkg_name()
|
|
142
|
-
module_dir = os.path.join(tempdir, pkg_name)
|
|
143
|
-
os.makedirs(module_dir)
|
|
144
|
-
create_file(
|
|
145
|
-
filepath=os.path.join(module_dir, f"__init__.py"), content=init_file_code
|
|
146
|
-
)
|
|
147
|
-
create_file(
|
|
148
|
-
filepath=os.path.join(module_dir, f"funcs.py"), content=funcs_file_code
|
|
149
|
-
)
|
|
150
|
-
server_url = api.openapi_spec["servers"][0]["url"]
|
|
151
|
-
pkg_version = pkg_version or "1.0.0"
|
|
152
|
-
setup_cfg_content = SETUP_CFG_TPL.format(
|
|
153
|
-
name=pkg_name,
|
|
154
|
-
version=pkg_version,
|
|
155
|
-
description=pkg_description
|
|
156
|
-
or f"A client API to consume the webservices exposed at {server_url}",
|
|
157
|
-
author=pkg_author or "API Package Maker",
|
|
158
|
-
license=pkg_license or "Apache",
|
|
159
|
-
)
|
|
160
|
-
create_file(
|
|
161
|
-
filepath=os.path.join(tempdir, f"setup.cfg"), content=setup_cfg_content
|
|
162
|
-
)
|
|
163
|
-
create_file(filepath=os.path.join(tempdir, f"setup.py"), content=SETUP_PY)
|
|
164
|
-
build = subprocess.run(
|
|
165
|
-
[sys.executable, "setup.py", "sdist"],
|
|
166
|
-
cwd=tempdir,
|
|
167
|
-
capture_output=True,
|
|
168
|
-
text=True,
|
|
169
|
-
)
|
|
170
|
-
if build.returncode != 0:
|
|
171
|
-
# check=True would raise a CalledProcessError carrying only the exit
|
|
172
|
-
# status, discarding the captured output that says what went wrong.
|
|
173
|
-
raise RuntimeError(
|
|
174
|
-
f"`setup.py sdist` failed (exit {build.returncode}) while building "
|
|
175
|
-
f"{pkg_name!r}.\n--- stdout ---\n{build.stdout}"
|
|
176
|
-
f"\n--- stderr ---\n{build.stderr}"
|
|
177
|
-
)
|
|
178
|
-
pkg_filename = f"{pkg_name}-{pkg_version}.tar.gz"
|
|
179
|
-
if not os.path.exists(OUTPUT_DIR):
|
|
180
|
-
os.makedirs(OUTPUT_DIR)
|
|
181
|
-
shutil.copy(src=os.path.join(tempdir, "dist", pkg_filename), dst=OUTPUT_DIR)
|
|
182
|
-
|
|
183
|
-
return os.path.join(OUTPUT_DIR, pkg_filename)
|
|
184
|
-
|
|
185
|
-
finally:
|
|
186
|
-
shutil.rmtree(tempdir)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def _check_build_backend():
|
|
190
|
-
"""Fail early and legibly when the interpreter cannot build an sdist.
|
|
191
|
-
|
|
192
|
-
``setup.py sdist`` needs setuptools in the *subprocess's* interpreter, which
|
|
193
|
-
is this one. Environments created by ``uv`` (and ``python -m venv
|
|
194
|
-
--without-pip``) do not have it. Without this check the user sees a bare
|
|
195
|
-
``CalledProcessError`` with an exit status and no cause.
|
|
196
|
-
"""
|
|
197
|
-
import importlib.util
|
|
198
|
-
|
|
199
|
-
if importlib.util.find_spec("setuptools") is None:
|
|
200
|
-
raise RuntimeError(
|
|
201
|
-
"mk_api_pkg builds a source distribution with `setup.py sdist`, "
|
|
202
|
-
f"which needs setuptools in {sys.executable}. Install it with: "
|
|
203
|
-
f"{sys.executable} -m pip install setuptools"
|
|
204
|
-
)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def main():
|
|
208
|
-
"""Entry point for the ``api-pkg-maker`` console script; returns the exit code.
|
|
209
|
-
|
|
210
|
-
``cw.dispatch`` returns the code rather than exiting, and
|
|
211
|
-
``[project.scripts]`` wraps this in ``sys.exit(main())``.
|
|
212
|
-
"""
|
|
213
|
-
import cw
|
|
214
|
-
|
|
215
|
-
return cw.dispatch(mk_api_pkg)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if __name__ == "__main__":
|
|
219
|
-
raise SystemExit(main())
|
|
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
|