http2py 0.1.34__tar.gz → 0.1.36__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.34 → http2py-0.1.36}/PKG-INFO +1 -2
- http2py-0.1.36/http2py/api_pkg_maker.py +360 -0
- {http2py-0.1.34 → http2py-0.1.36}/http2py/cli_maker.py +20 -2
- {http2py-0.1.34 → http2py-0.1.36}/http2py/client.py +20 -1
- http2py-0.1.36/http2py/decorators.py +144 -0
- {http2py-0.1.34 → http2py-0.1.36}/http2py/py2request.py +52 -7
- {http2py-0.1.34 → http2py-0.1.36}/http2py/tests/api_pkg_maker_test.py +4 -2
- {http2py-0.1.34 → http2py-0.1.36}/pyproject.toml +1 -7
- http2py-0.1.36/tests/test_binary_payloads.py +189 -0
- http2py-0.1.36/tests/test_error_statuses.py +25 -0
- http2py-0.1.36/tests/test_generated_clients.py +184 -0
- http2py-0.1.34/http2py/api_pkg_maker.py +0 -219
- http2py-0.1.34/http2py/decorators.py +0 -59
- http2py-0.1.34/tests/test_dependency_workarounds.py +0 -85
- {http2py-0.1.34 → http2py-0.1.36}/.gitattributes +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/.github/workflows/ci.yml +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/.gitignore +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/LICENSE +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/README.md +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/conftest.py +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/http2py/__init__.py +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/http2py/authentication.py +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/http2py/constants.py +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/http2py/default_configs.py +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/http2py/example_cli.py +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/http2py/global_state.py +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/http2py/http2py_cls.py +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/http2py/resources/module_maker/func_tpl.txt +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/http2py/testing_utils.py +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/http2py/tests/__init__.py +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/http2py/tests/conftest.py +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/http2py/tests/test_requirements.txt +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/http2py/util.py +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/tests/cli_goldens/mk_cli.json +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/tests/spec_fixture.py +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/tests/test_ci_collection_contract.py +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/tests/test_cli_surface.py +0 -0
- {http2py-0.1.34 → http2py-0.1.36}/tests/test_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: http2py
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.36
|
|
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
|
|
@@ -17,7 +17,6 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
17
17
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
18
|
Requires-Python: >=3.10
|
|
19
19
|
Requires-Dist: cw<0.2,>=0.1.1
|
|
20
|
-
Requires-Dist: dill
|
|
21
20
|
Requires-Dist: glom
|
|
22
21
|
Requires-Dist: i2
|
|
23
22
|
Requires-Dist: importlib-resources
|
|
@@ -0,0 +1,360 @@
|
|
|
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.{func_name})
|
|
83
|
+
def {func_name}(*args, **kwargs):
|
|
84
|
+
return api.{func_name}(*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 = [
|
|
162
|
+
name
|
|
163
|
+
for name, method in vars(api).items()
|
|
164
|
+
if getattr(method, "method_spec", None)
|
|
165
|
+
]
|
|
166
|
+
for name in names:
|
|
167
|
+
if not _is_plain_identifier(name):
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"Route name {name[:60]!r} is not an ASCII Python identifier: cannot "
|
|
170
|
+
"generate a function for it."
|
|
171
|
+
)
|
|
172
|
+
if name.startswith("_") or name in _RESERVED_ROUTE_NAMES:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"Route name {name!r} is reserved (it starts with '_' or is 'api')."
|
|
175
|
+
)
|
|
176
|
+
if not names:
|
|
177
|
+
raise RuntimeError("The API is empty")
|
|
178
|
+
return names
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _cfg_value(text: str) -> str:
|
|
182
|
+
"""``text`` as a setup.cfg value: one line, ``%`` escaped from interpolation."""
|
|
183
|
+
return " ".join(str(text).split()).replace("%", "%%")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def api_pkg_files(
|
|
187
|
+
openapi_spec: dict = None,
|
|
188
|
+
openapi_url: str = None,
|
|
189
|
+
pkg_name: str = None,
|
|
190
|
+
pkg_version: str = None,
|
|
191
|
+
pkg_description: str = None,
|
|
192
|
+
pkg_author: str = None,
|
|
193
|
+
pkg_license: str = None,
|
|
194
|
+
*,
|
|
195
|
+
binary_loads: Optional[Union[str, Callable]] = None,
|
|
196
|
+
) -> dict:
|
|
197
|
+
"""The source files of the package :func:`mk_api_pkg` builds, as
|
|
198
|
+
``{relative path: text}`` (see :func:`mk_api_pkg` for the arguments)."""
|
|
199
|
+
if isinstance(openapi_spec, str): # e.g. given as JSON text on the command line
|
|
200
|
+
openapi_spec = json.loads(openapi_spec)
|
|
201
|
+
api = HttpClient(openapi_spec=openapi_spec, url=openapi_url)
|
|
202
|
+
func_names = _route_names(api)
|
|
203
|
+
loads_code = (
|
|
204
|
+
"None"
|
|
205
|
+
if binary_loads is None
|
|
206
|
+
else (f"_import_object({object_ref(binary_loads)!r})")
|
|
207
|
+
)
|
|
208
|
+
pkg_name = pkg_name or _mk_pkg_name()
|
|
209
|
+
if not _is_plain_identifier(pkg_name):
|
|
210
|
+
raise ValueError(f"pkg_name must be an ASCII Python identifier: {pkg_name!r}")
|
|
211
|
+
server_url = api.openapi_spec["servers"][0]["url"]
|
|
212
|
+
setup_cfg = SETUP_CFG_TPL.format(
|
|
213
|
+
name=pkg_name,
|
|
214
|
+
version=_cfg_value(pkg_version or "1.0.0"),
|
|
215
|
+
description=_cfg_value(
|
|
216
|
+
pkg_description
|
|
217
|
+
or f"A client API to consume the webservices exposed at {server_url}"
|
|
218
|
+
),
|
|
219
|
+
author=_cfg_value(pkg_author or "API Package Maker"),
|
|
220
|
+
license=_cfg_value(pkg_license or "Apache"),
|
|
221
|
+
spec_filename=SPEC_FILENAME,
|
|
222
|
+
)
|
|
223
|
+
funcs_code = FUNCS_FILE_TPL.format(
|
|
224
|
+
spec_filename=SPEC_FILENAME,
|
|
225
|
+
binary_loads=loads_code,
|
|
226
|
+
funcs="".join(FUNC_TPL.format(func_name=name) for name in func_names),
|
|
227
|
+
)
|
|
228
|
+
return {
|
|
229
|
+
"setup.py": SETUP_PY,
|
|
230
|
+
"setup.cfg": setup_cfg,
|
|
231
|
+
f"{pkg_name}/__init__.py": INIT_FILE_TPL.format(funcs=", ".join(func_names)),
|
|
232
|
+
f"{pkg_name}/funcs.py": funcs_code,
|
|
233
|
+
f"{pkg_name}/{SPEC_FILENAME}": json.dumps(api.openapi_spec, indent=1),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _mk_pkg_name():
|
|
238
|
+
return f"apipkg{int(datetime.now(timezone.utc).timestamp())}"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def mk_api_pkg(
|
|
242
|
+
openapi_spec: dict = None,
|
|
243
|
+
openapi_url: str = None,
|
|
244
|
+
pkg_name: str = None,
|
|
245
|
+
pkg_version: str = None,
|
|
246
|
+
pkg_description: str = None,
|
|
247
|
+
pkg_author: str = None,
|
|
248
|
+
pkg_license: str = None,
|
|
249
|
+
*,
|
|
250
|
+
binary_loads: Optional[Union[str, Callable]] = None,
|
|
251
|
+
output_dir: str = None,
|
|
252
|
+
):
|
|
253
|
+
"""
|
|
254
|
+
Build a pip installable package containing the functions to consume a running
|
|
255
|
+
webservice application executed with py2http.
|
|
256
|
+
|
|
257
|
+
The package holds the spec as it was when built (fetched from ``openapi_url``
|
|
258
|
+
if no ``openapi_spec`` is given), as a JSON file it loads at import.
|
|
259
|
+
|
|
260
|
+
:param openapi_spec: The openapi specification for the webservice.
|
|
261
|
+
:type openapi_spec: dict, optional
|
|
262
|
+
:param openapi_url: The url to fetch the openapi specification for the webservice.
|
|
263
|
+
:type openapi_url: str, optional
|
|
264
|
+
:param pkg_name: The name of the package. If no name is provided, default to
|
|
265
|
+
'apipkg{timestamp}'
|
|
266
|
+
:type pkg_name: str, optional
|
|
267
|
+
:param pkg_version: The version number of the package. If no version is provided,
|
|
268
|
+
default to 1.0.0
|
|
269
|
+
:type pkg_version: str, optional
|
|
270
|
+
:param pkg_description: The description of the package.
|
|
271
|
+
:type pkg_description: str, optional
|
|
272
|
+
:param pkg_author: The author of the package. If no author provided, default to
|
|
273
|
+
'API Package Maker'
|
|
274
|
+
:type pkg_author: str, optional
|
|
275
|
+
:param pkg_license: The license for the package. If no license provided, default
|
|
276
|
+
to 'Apache'
|
|
277
|
+
:type pkg_license: str, optional
|
|
278
|
+
:param binary_loads: How the package decodes binary (octet-stream) responses:
|
|
279
|
+
an importable callable or its dotted path (``"builtins.bytes"`` for the raw
|
|
280
|
+
content, ``"http2py.decorators.unsafe_pickle_loads"`` for a fully trusted
|
|
281
|
+
server). By default they raise ``BinaryResponseNotDecoded``.
|
|
282
|
+
:param output_dir: Where the built ``.tar.gz`` is written (default ``OUTPUT_DIR``).
|
|
283
|
+
"""
|
|
284
|
+
_check_build_backend()
|
|
285
|
+
output_dir = OUTPUT_DIR if output_dir is None else output_dir
|
|
286
|
+
files = api_pkg_files(
|
|
287
|
+
openapi_spec,
|
|
288
|
+
openapi_url,
|
|
289
|
+
pkg_name,
|
|
290
|
+
pkg_version,
|
|
291
|
+
pkg_description,
|
|
292
|
+
pkg_author,
|
|
293
|
+
pkg_license,
|
|
294
|
+
binary_loads=binary_loads,
|
|
295
|
+
)
|
|
296
|
+
pkg_name = next(path.split("/")[0] for path in files if "/" in path)
|
|
297
|
+
|
|
298
|
+
tempdir = tempfile.mkdtemp()
|
|
299
|
+
try:
|
|
300
|
+
for relpath, content in files.items():
|
|
301
|
+
filepath = os.path.join(tempdir, *relpath.split("/"))
|
|
302
|
+
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
|
303
|
+
with open(filepath, "w", encoding="utf-8") as file:
|
|
304
|
+
file.write(content)
|
|
305
|
+
build = subprocess.run(
|
|
306
|
+
[sys.executable, "setup.py", "sdist"],
|
|
307
|
+
cwd=tempdir,
|
|
308
|
+
capture_output=True,
|
|
309
|
+
text=True,
|
|
310
|
+
)
|
|
311
|
+
if build.returncode != 0:
|
|
312
|
+
# check=True would raise a CalledProcessError carrying only the exit
|
|
313
|
+
# status, discarding the captured output that says what went wrong.
|
|
314
|
+
raise RuntimeError(
|
|
315
|
+
f"`setup.py sdist` failed (exit {build.returncode}) while building "
|
|
316
|
+
f"{pkg_name!r}.\n--- stdout ---\n{build.stdout}"
|
|
317
|
+
f"\n--- stderr ---\n{build.stderr}"
|
|
318
|
+
)
|
|
319
|
+
# setuptools normalizes the name and version in the file name it writes
|
|
320
|
+
(pkg_filename,) = os.listdir(os.path.join(tempdir, "dist"))
|
|
321
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
322
|
+
shutil.copy(src=os.path.join(tempdir, "dist", pkg_filename), dst=output_dir)
|
|
323
|
+
|
|
324
|
+
return os.path.join(output_dir, pkg_filename)
|
|
325
|
+
|
|
326
|
+
finally:
|
|
327
|
+
shutil.rmtree(tempdir)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _check_build_backend():
|
|
331
|
+
"""Fail early and legibly when the interpreter cannot build an sdist.
|
|
332
|
+
|
|
333
|
+
``setup.py sdist`` needs setuptools in the *subprocess's* interpreter, which
|
|
334
|
+
is this one. Environments created by ``uv`` (and ``python -m venv
|
|
335
|
+
--without-pip``) do not have it. Without this check the user sees a bare
|
|
336
|
+
``CalledProcessError`` with an exit status and no cause.
|
|
337
|
+
"""
|
|
338
|
+
import importlib.util
|
|
339
|
+
|
|
340
|
+
if importlib.util.find_spec("setuptools") is None:
|
|
341
|
+
raise RuntimeError(
|
|
342
|
+
"mk_api_pkg builds a source distribution with `setup.py sdist`, "
|
|
343
|
+
f"which needs setuptools in {sys.executable}. Install it with: "
|
|
344
|
+
f"{sys.executable} -m pip install setuptools"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def main():
|
|
349
|
+
"""Entry point for the ``api-pkg-maker`` console script; returns the exit code.
|
|
350
|
+
|
|
351
|
+
``cw.dispatch`` returns the code rather than exiting, and
|
|
352
|
+
``[project.scripts]`` wraps this in ``sys.exit(main())``.
|
|
353
|
+
"""
|
|
354
|
+
import cw
|
|
355
|
+
|
|
356
|
+
return cw.dispatch(mk_api_pkg)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
if __name__ == "__main__":
|
|
360
|
+
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,7 +136,13 @@ 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(
|
|
139
|
+
register_cli_method(
|
|
140
|
+
openapi_spec,
|
|
141
|
+
method,
|
|
142
|
+
expected_auth_kwargs,
|
|
143
|
+
config_filename,
|
|
144
|
+
binary_loads=binary_loads,
|
|
145
|
+
)
|
|
132
146
|
for methodname, method in client_details.__dict__.items()
|
|
133
147
|
if getattr(method, "method_spec", None)
|
|
134
148
|
]
|
|
@@ -157,6 +171,8 @@ def register_cli_method(
|
|
|
157
171
|
expected_auth_kwargs: Iterable[str] = None,
|
|
158
172
|
config_filename: str = DFLT_CONFIG_FILENAME,
|
|
159
173
|
profile: str = "",
|
|
174
|
+
*,
|
|
175
|
+
binary_loads: Callable = None,
|
|
160
176
|
):
|
|
161
177
|
"""Creates a CLI-friendly function to instantiate an HttpClient with appropriate authentication
|
|
162
178
|
arguments and call a particular method of the client instance
|
|
@@ -168,6 +184,8 @@ def register_cli_method(
|
|
|
168
184
|
:param config_filename: The path to a JSON file to read for authentication kwargs
|
|
169
185
|
(defaults to ~/.http2py/credentials.json)
|
|
170
186
|
:param profile: The named profile to find in the config file
|
|
187
|
+
:param binary_loads: How binary (octet-stream) responses are decoded (see
|
|
188
|
+
:func:`mk_cli`)
|
|
171
189
|
"""
|
|
172
190
|
methodname = client_method.__name__
|
|
173
191
|
method_sig = Sig(client_method)
|
|
@@ -189,7 +207,7 @@ def register_cli_method(
|
|
|
189
207
|
auth_kwargs = mk_auth(
|
|
190
208
|
auth_kwargs, expected_auth_kwargs, config_filename, profile
|
|
191
209
|
)
|
|
192
|
-
http_client = HttpClient(openapi_spec, **auth_kwargs)
|
|
210
|
+
http_client = HttpClient(openapi_spec, binary_loads=binary_loads, **auth_kwargs)
|
|
193
211
|
return getattr(http_client, methodname)(**kwargs)
|
|
194
212
|
|
|
195
213
|
method_sig.wrap(cli_method)
|
|
@@ -10,9 +10,11 @@ from i2.errors import AuthorizationError
|
|
|
10
10
|
|
|
11
11
|
from http2py.authentication import mk_auth, DFLT_CONFIG_FILENAME
|
|
12
12
|
from http2py.py2request import (
|
|
13
|
+
DFLT_BINARY_DUMPS,
|
|
13
14
|
mk_method_spec_from_openapi_method_spec,
|
|
14
15
|
mk_request_function,
|
|
15
16
|
)
|
|
17
|
+
from http2py.decorators import check_loads
|
|
16
18
|
from http2py.global_state import get_global_state
|
|
17
19
|
|
|
18
20
|
|
|
@@ -37,6 +39,9 @@ class HttpClient:
|
|
|
37
39
|
session_state=None,
|
|
38
40
|
base_url=None,
|
|
39
41
|
verify_cert=True,
|
|
42
|
+
*,
|
|
43
|
+
binary_loads=None,
|
|
44
|
+
binary_dumps=DFLT_BINARY_DUMPS,
|
|
40
45
|
**auth_kwargs,
|
|
41
46
|
):
|
|
42
47
|
"""
|
|
@@ -47,6 +52,13 @@ class HttpClient:
|
|
|
47
52
|
:param session: A session for HTTP requests
|
|
48
53
|
:param base_url: A base url to enforce if you don't want to take the base url from the OpenAPI spec
|
|
49
54
|
:param verify_cert: If True, it verifies the SSL certificate validity for each https request.
|
|
55
|
+
:param binary_loads: How to decode binary (octet-stream) responses. By
|
|
56
|
+
default they are NOT unpickled (that would let the server run code on
|
|
57
|
+
this client) and raise ``BinaryResponseNotDecoded``; pass ``bytes``,
|
|
58
|
+
a decoder of your own, or ``http2py.decorators.unsafe_pickle_loads``
|
|
59
|
+
if the server is fully trusted.
|
|
60
|
+
:param binary_dumps: How to encode binary request bodies (default
|
|
61
|
+
``pickle.dumps``).
|
|
50
62
|
|
|
51
63
|
:Keyword Arguments:
|
|
52
64
|
* *api_key*
|
|
@@ -56,6 +68,9 @@ class HttpClient:
|
|
|
56
68
|
|
|
57
69
|
"""
|
|
58
70
|
self.verify_cert = verify_cert
|
|
71
|
+
check_loads(binary_loads)
|
|
72
|
+
self.binary_loads = binary_loads
|
|
73
|
+
self.binary_dumps = binary_dumps
|
|
59
74
|
if url and not openapi_spec:
|
|
60
75
|
openapi_spec = get(url, verify=self.verify_cert).json()
|
|
61
76
|
self.openapi_spec = openapi_spec
|
|
@@ -125,7 +140,11 @@ class HttpClient:
|
|
|
125
140
|
content_type=content_type,
|
|
126
141
|
)
|
|
127
142
|
func = mk_request_function(
|
|
128
|
-
method_spec,
|
|
143
|
+
method_spec,
|
|
144
|
+
dispatch=self.handle_request,
|
|
145
|
+
verify_cert=self.verify_cert,
|
|
146
|
+
binary_loads=self.binary_loads,
|
|
147
|
+
binary_dumps=self.binary_dumps,
|
|
129
148
|
)
|
|
130
149
|
func.method_spec = method_spec
|
|
131
150
|
func.content_type = content_type
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Response handling: map http status codes to exceptions, decode the payload.
|
|
2
|
+
|
|
3
|
+
``handle_json_resp`` / ``handle_binary_resp`` / ``handle_raw_resp`` wrap an
|
|
4
|
+
output transformation so it only ever sees a successful response of the
|
|
5
|
+
expected content type.
|
|
6
|
+
|
|
7
|
+
Binary (``application/octet-stream``) responses are never unpickled unless asked:
|
|
8
|
+
unpickling runs code chosen by whoever produced the bytes, so a spoofed or
|
|
9
|
+
compromised server could run code on the client. Pass ``loads=`` to
|
|
10
|
+
``handle_binary_resp`` (or ``binary_loads=`` to ``mk_request_function`` /
|
|
11
|
+
``HttpClient``): ``bytes`` for the raw content, a decoder of your own, or
|
|
12
|
+
``unsafe_pickle_loads`` if (and only if) the server is fully trusted.
|
|
13
|
+
|
|
14
|
+
>>> class Resp:
|
|
15
|
+
... status_code, content = 200, b'raw bytes'
|
|
16
|
+
>>> handle_binary_resp(lambda x: x, loads=bytes)(Resp())
|
|
17
|
+
b'raw bytes'
|
|
18
|
+
>>> handle_binary_resp(lambda x: x)(Resp()) # doctest: +ELLIPSIS
|
|
19
|
+
Traceback (most recent call last):
|
|
20
|
+
...
|
|
21
|
+
http2py.decorators.BinaryResponseNotDecoded: Got a binary response but no loads=... to decode it. ...
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import pickle
|
|
26
|
+
import sys
|
|
27
|
+
import warnings
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from functools import partial
|
|
30
|
+
from typing import Any, Optional
|
|
31
|
+
|
|
32
|
+
from i2.errors import (
|
|
33
|
+
AuthorizationError,
|
|
34
|
+
ForbiddenError,
|
|
35
|
+
InputError,
|
|
36
|
+
NotFoundError,
|
|
37
|
+
DuplicateRecordError,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from http2py.constants import BINARY_CONTENT_TYPE, JSON_CONTENT_TYPE, RAW_CONTENT_TYPE
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _handle_error(resp):
|
|
44
|
+
if resp.status_code == 400:
|
|
45
|
+
if resp.reason == "AuthorizationError":
|
|
46
|
+
raise AuthorizationError(resp.text)
|
|
47
|
+
if resp.reason == "InputError":
|
|
48
|
+
raise InputError(resp.text)
|
|
49
|
+
if resp.reason == "DuplicateRecordError":
|
|
50
|
+
raise DuplicateRecordError(resp.text)
|
|
51
|
+
if resp.status_code == 401: # py2http answers AuthorizationError with 401
|
|
52
|
+
raise AuthorizationError(resp.text)
|
|
53
|
+
if resp.status_code == 403:
|
|
54
|
+
raise ForbiddenError(resp.text)
|
|
55
|
+
if resp.status_code == 404:
|
|
56
|
+
raise NotFoundError(resp.text)
|
|
57
|
+
raise RuntimeError(resp.text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class BinaryResponseNotDecoded(ValueError):
|
|
61
|
+
"""A binary response arrived, but no ``loads`` was given to decode it."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def unsafe_pickle_loads(data: bytes) -> Any:
|
|
65
|
+
"""Unpickle ``data``. UNSAFE on anything an untrusted server can send.
|
|
66
|
+
|
|
67
|
+
Unpickling runs code chosen by whoever produced the bytes, so use this only
|
|
68
|
+
when the server (and the network path to it) is fully trusted. It exists so
|
|
69
|
+
that opting into pickled responses is explicit and visible at the call site::
|
|
70
|
+
|
|
71
|
+
HttpClient(spec, binary_loads=unsafe_pickle_loads)
|
|
72
|
+
"""
|
|
73
|
+
return pickle.loads(data)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) + os.sep
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _stacklevel_outside_package() -> int:
|
|
80
|
+
"""The ``stacklevel`` (for a ``warn`` called by our caller) of the first frame
|
|
81
|
+
outside http2py, so a warning points at the user's line, however deep in
|
|
82
|
+
http2py it was raised; and repeated checks of one call collapse into one."""
|
|
83
|
+
frame, level = sys._getframe(1), 1
|
|
84
|
+
while frame is not None and frame.f_code.co_filename.startswith(_PACKAGE_DIR):
|
|
85
|
+
frame, level = frame.f_back, level + 1
|
|
86
|
+
return level
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def check_loads(loads) -> None:
|
|
90
|
+
"""Validate a ``loads=`` / ``binary_loads=`` argument: ``None`` or a callable.
|
|
91
|
+
|
|
92
|
+
``pickle.loads`` itself gets a warning suggesting ``unsafe_pickle_loads``.
|
|
93
|
+
That is advice, not a guard: any other callable (a ``partial`` of
|
|
94
|
+
``pickle.loads``, ``dill.loads``, ...) is trusted as given.
|
|
95
|
+
"""
|
|
96
|
+
if loads is None:
|
|
97
|
+
return
|
|
98
|
+
if not callable(loads):
|
|
99
|
+
raise TypeError(f"loads must be callable, got {loads!r}")
|
|
100
|
+
if loads is pickle.loads:
|
|
101
|
+
warnings.warn(
|
|
102
|
+
"loads=pickle.loads unpickles server data, which lets a spoofed or "
|
|
103
|
+
"compromised server run code on this client. Use "
|
|
104
|
+
"loads=unsafe_pickle_loads to make that choice explicit, and only if "
|
|
105
|
+
"the server is fully trusted.",
|
|
106
|
+
stacklevel=_stacklevel_outside_package(),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _handle_resp(func, content_type, *, loads: Optional[Callable[[bytes], Any]] = None):
|
|
111
|
+
check_loads(loads)
|
|
112
|
+
|
|
113
|
+
def output_trans(resp):
|
|
114
|
+
if resp.status_code == 200:
|
|
115
|
+
if content_type == JSON_CONTENT_TYPE:
|
|
116
|
+
output = resp.json()
|
|
117
|
+
elif content_type == BINARY_CONTENT_TYPE:
|
|
118
|
+
if loads is None:
|
|
119
|
+
raise BinaryResponseNotDecoded(
|
|
120
|
+
"Got a binary response but no loads=... to decode it. "
|
|
121
|
+
"Binary responses are no longer unpickled by default, "
|
|
122
|
+
"since unpickling lets the server run code on this "
|
|
123
|
+
"client. Pass binary_loads=bytes for the raw content, a "
|
|
124
|
+
"decoder of your own, or binary_loads=unsafe_pickle_loads "
|
|
125
|
+
"(from http2py.decorators) if the server is fully trusted."
|
|
126
|
+
)
|
|
127
|
+
output = loads(resp.content)
|
|
128
|
+
elif content_type == RAW_CONTENT_TYPE:
|
|
129
|
+
output = resp.text
|
|
130
|
+
else:
|
|
131
|
+
raise NotImplementedError(
|
|
132
|
+
f"Response of type {content_type} is not supported yet."
|
|
133
|
+
)
|
|
134
|
+
return func(output)
|
|
135
|
+
else:
|
|
136
|
+
_handle_error(resp)
|
|
137
|
+
|
|
138
|
+
output_trans.content_type = content_type
|
|
139
|
+
return output_trans
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
handle_json_resp = partial(_handle_resp, content_type=JSON_CONTENT_TYPE)
|
|
143
|
+
handle_binary_resp = partial(_handle_resp, content_type=BINARY_CONTENT_TYPE)
|
|
144
|
+
handle_raw_resp = partial(_handle_resp, content_type=RAW_CONTENT_TYPE)
|