http2py 0.1.33__tar.gz → 0.1.35__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.33 → http2py-0.1.35}/PKG-INFO +3 -3
- http2py-0.1.35/conftest.py +18 -0
- {http2py-0.1.33 → http2py-0.1.35}/http2py/api_pkg_maker.py +61 -11
- {http2py-0.1.33 → http2py-0.1.35}/http2py/cli_maker.py +36 -8
- {http2py-0.1.33 → http2py-0.1.35}/http2py/client.py +20 -1
- http2py-0.1.35/http2py/decorators.py +142 -0
- {http2py-0.1.33 → http2py-0.1.35}/http2py/example_cli.py +9 -4
- {http2py-0.1.33 → http2py-0.1.35}/http2py/py2request.py +52 -7
- {http2py-0.1.33 → http2py-0.1.35}/pyproject.toml +16 -16
- http2py-0.1.35/tests/cli_goldens/mk_cli.json +10 -0
- http2py-0.1.35/tests/spec_fixture.py +89 -0
- http2py-0.1.35/tests/test_binary_payloads.py +189 -0
- {http2py-0.1.33 → http2py-0.1.35}/tests/test_ci_collection_contract.py +95 -19
- http2py-0.1.35/tests/test_cli_surface.py +171 -0
- http2py-0.1.33/conftest.py +0 -21
- http2py-0.1.33/http2py/decorators.py +0 -59
- http2py-0.1.33/tests/test_dependency_workarounds.py +0 -85
- {http2py-0.1.33 → http2py-0.1.35}/.gitattributes +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/.github/workflows/ci.yml +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/.gitignore +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/LICENSE +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/README.md +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/http2py/__init__.py +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/http2py/authentication.py +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/http2py/constants.py +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/http2py/default_configs.py +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/http2py/global_state.py +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/http2py/http2py_cls.py +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/http2py/resources/module_maker/func_tpl.txt +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/http2py/testing_utils.py +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/http2py/tests/__init__.py +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/http2py/tests/api_pkg_maker_test.py +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/http2py/tests/conftest.py +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/http2py/tests/test_requirements.txt +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/http2py/util.py +0 -0
- {http2py-0.1.33 → http2py-0.1.35}/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.35
|
|
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
|
|
@@ -16,8 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
16
16
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
17
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
18
|
Requires-Python: >=3.10
|
|
19
|
-
Requires-Dist:
|
|
20
|
-
Requires-Dist: dill
|
|
19
|
+
Requires-Dist: cw<0.2,>=0.1.1
|
|
21
20
|
Requires-Dist: glom
|
|
22
21
|
Requires-Dist: i2
|
|
23
22
|
Requires-Dist: importlib-resources
|
|
@@ -28,6 +27,7 @@ Provides-Extra: dev
|
|
|
28
27
|
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
29
28
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
30
29
|
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: setuptools; extra == 'dev'
|
|
31
31
|
Description-Content-Type: text/markdown
|
|
32
32
|
|
|
33
33
|
**Note: A new, lighterweight, more modern replacement: [ho](https://github.com/i2mint/ho)**
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Pytest configuration for the http2py repository.
|
|
2
|
+
|
|
3
|
+
``http2py/tests`` stands up a real ``py2http`` web service, so it depends on a
|
|
4
|
+
test-only package that is not one of http2py's declared dependencies. Under
|
|
5
|
+
``--doctest-modules`` an unimportable module is not a single failing test: it
|
|
6
|
+
aborts collection for the whole session. So that directory is excluded here.
|
|
7
|
+
|
|
8
|
+
CI excludes the same path via ``[tool.wads.ci.testing].exclude_paths`` in
|
|
9
|
+
pyproject.toml, which the wads ``run-tests-uv`` action turns into ``--ignore``
|
|
10
|
+
flags. Repeating it here is what makes a bare ``pytest`` (no flags, e.g. a local
|
|
11
|
+
run or an editor's test runner) behave the same way as CI.
|
|
12
|
+
``tests/test_ci_collection_contract.py`` asserts the two lists stay in
|
|
13
|
+
agreement, so they cannot drift apart silently.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
collect_ignore = [
|
|
17
|
+
"http2py/tests",
|
|
18
|
+
]
|
|
@@ -3,21 +3,32 @@
|
|
|
3
3
|
Given a spec (or the URL of one), :func:`mk_api_pkg` writes a small source
|
|
4
4
|
distribution whose functions are bound to the service's routes.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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.
|
|
10
18
|
"""
|
|
11
19
|
|
|
12
|
-
import argh
|
|
13
20
|
import os
|
|
14
21
|
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
15
24
|
import tempfile
|
|
16
25
|
from http2py.client import HttpClient
|
|
17
26
|
from datetime import datetime, timezone
|
|
18
|
-
from setuptools import sandbox
|
|
19
27
|
|
|
20
|
-
|
|
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")
|
|
21
32
|
|
|
22
33
|
INIT_FILE_TPL = """from .funcs import {funcs}
|
|
23
34
|
"""
|
|
@@ -111,6 +122,8 @@ def mk_api_pkg(
|
|
|
111
122
|
with open(filepath, "w") as file:
|
|
112
123
|
print(content, file=file)
|
|
113
124
|
|
|
125
|
+
_check_build_backend()
|
|
126
|
+
|
|
114
127
|
tempdir = tempfile.mkdtemp()
|
|
115
128
|
try:
|
|
116
129
|
api = HttpClient(openapi_spec=openapi_spec, url=openapi_url)
|
|
@@ -148,8 +161,20 @@ def mk_api_pkg(
|
|
|
148
161
|
filepath=os.path.join(tempdir, f"setup.cfg"), content=setup_cfg_content
|
|
149
162
|
)
|
|
150
163
|
create_file(filepath=os.path.join(tempdir, f"setup.py"), content=SETUP_PY)
|
|
151
|
-
|
|
152
|
-
|
|
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
|
+
)
|
|
153
178
|
pkg_filename = f"{pkg_name}-{pkg_version}.tar.gz"
|
|
154
179
|
if not os.path.exists(OUTPUT_DIR):
|
|
155
180
|
os.makedirs(OUTPUT_DIR)
|
|
@@ -161,9 +186,34 @@ def mk_api_pkg(
|
|
|
161
186
|
shutil.rmtree(tempdir)
|
|
162
187
|
|
|
163
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
|
+
|
|
164
207
|
def main():
|
|
165
|
-
|
|
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)
|
|
166
216
|
|
|
167
217
|
|
|
168
218
|
if __name__ == "__main__":
|
|
169
|
-
main()
|
|
219
|
+
raise SystemExit(main())
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
"""Turn an http-bound python object into a command line interface.
|
|
2
2
|
|
|
3
3
|
Signatures are first made argparse-friendly (:func:`mk_argparse_friendly`),
|
|
4
|
-
then
|
|
4
|
+
then turned into a parser by :func:`mk_cli` and run by :func:`dispatch_cli`.
|
|
5
|
+
|
|
6
|
+
The parser is built by ``cw``. Its ``BY_NAME_IF_KWONLY`` naming is what
|
|
7
|
+
reproduces the grammar this module has always produced -- see
|
|
8
|
+
:data:`CLI_CONVENTION`.
|
|
5
9
|
"""
|
|
6
10
|
|
|
7
|
-
import
|
|
11
|
+
import dataclasses
|
|
8
12
|
from functools import wraps
|
|
9
13
|
from glom import glom
|
|
10
14
|
from inspect import signature
|
|
@@ -15,11 +19,30 @@ import requests
|
|
|
15
19
|
from collections.abc import Callable, Iterable
|
|
16
20
|
import yaml
|
|
17
21
|
|
|
22
|
+
import cw
|
|
23
|
+
|
|
18
24
|
from i2.io_trans import JSONAnnotAndDfltIoTrans
|
|
19
25
|
from i2.signatures import set_signature_of_func, Sig, KO
|
|
20
26
|
from http2py import HttpClient
|
|
21
27
|
from http2py.authentication import mk_auth, DFLT_CONFIG_FILENAME
|
|
22
28
|
|
|
29
|
+
#: How the command line is derived from a signature.
|
|
30
|
+
#:
|
|
31
|
+
#: Every function :func:`register_cli_method` builds has KEYWORD-ONLY parameters
|
|
32
|
+
#: only (plus ``*args``/``**kwargs`` on a no-argument route), because
|
|
33
|
+
#: :func:`Sig.merge_with_sig` is called with ``kind=KO``. For that shape,
|
|
34
|
+
#: ``BY_NAME_IF_KWONLY`` is what reproduces the grammar this module produced
|
|
35
|
+
#: under argh: every parameter is an option, required exactly when it has no
|
|
36
|
+
#: default -- ``get-thing -u UID -p PID [-l LIMIT]``.
|
|
37
|
+
#:
|
|
38
|
+
#: cw's ``ARGH`` default (``BY_NAME_IF_HAS_DEFAULT``) would NOT: it turns a
|
|
39
|
+
#: keyword-only parameter with no default into a *positional*, so every required
|
|
40
|
+
#: API argument would silently change spelling. That is not a cw bug -- it
|
|
41
|
+
#: faithfully reproduces argh's *explicit* ``BY_NAME_IF_HAS_DEFAULT`` policy.
|
|
42
|
+
#: This module never selected a policy, and argh's no-policy legacy mode is the
|
|
43
|
+
#: one that matches ``BY_NAME_IF_KWONLY`` here.
|
|
44
|
+
CLI_CONVENTION = dataclasses.replace(cw.ARGH, naming=cw.BY_NAME_IF_KWONLY)
|
|
45
|
+
|
|
23
46
|
|
|
24
47
|
def mk_sig_argparse_friendly(sig):
|
|
25
48
|
"""Modifies a signature to change all leading underscores in param names
|
|
@@ -109,15 +132,20 @@ def mk_cli(
|
|
|
109
132
|
for methodname, method in client_details.__dict__.items()
|
|
110
133
|
if getattr(method, "method_spec", None)
|
|
111
134
|
]
|
|
112
|
-
|
|
113
|
-
parser.add_commands(cli_methods)
|
|
114
|
-
return parser
|
|
135
|
+
return cw.mk_parser(cli_methods, convention=CLI_CONVENTION)
|
|
115
136
|
|
|
116
137
|
|
|
117
138
|
def dispatch_cli(*args, **kwargs):
|
|
118
|
-
"""Makes a CLI parser with mk_cli and then
|
|
119
|
-
|
|
120
|
-
|
|
139
|
+
"""Makes a CLI parser with mk_cli and then runs it (see documentation of mk_cli)
|
|
140
|
+
|
|
141
|
+
Returns ``None`` when the command ran, and raises ``SystemExit(2)`` on a
|
|
142
|
+
command-line error -- the same two outcomes the previous argh-based
|
|
143
|
+
implementation produced. ``cw.run`` *returns* the exit code rather than
|
|
144
|
+
exiting, so the non-zero case is re-raised here.
|
|
145
|
+
"""
|
|
146
|
+
code = cw.run(mk_cli(*args, **kwargs))
|
|
147
|
+
if code:
|
|
148
|
+
raise SystemExit(code)
|
|
121
149
|
|
|
122
150
|
|
|
123
151
|
set_signature_of_func(dispatch_cli, signature(mk_cli))
|
|
@@ -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,142 @@
|
|
|
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 == 403:
|
|
52
|
+
raise ForbiddenError(resp.text)
|
|
53
|
+
if resp.status_code == 404:
|
|
54
|
+
raise NotFoundError(resp.text)
|
|
55
|
+
raise RuntimeError(resp.text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BinaryResponseNotDecoded(ValueError):
|
|
59
|
+
"""A binary response arrived, but no ``loads`` was given to decode it."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def unsafe_pickle_loads(data: bytes) -> Any:
|
|
63
|
+
"""Unpickle ``data``. UNSAFE on anything an untrusted server can send.
|
|
64
|
+
|
|
65
|
+
Unpickling runs code chosen by whoever produced the bytes, so use this only
|
|
66
|
+
when the server (and the network path to it) is fully trusted. It exists so
|
|
67
|
+
that opting into pickled responses is explicit and visible at the call site::
|
|
68
|
+
|
|
69
|
+
HttpClient(spec, binary_loads=unsafe_pickle_loads)
|
|
70
|
+
"""
|
|
71
|
+
return pickle.loads(data)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) + os.sep
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _stacklevel_outside_package() -> int:
|
|
78
|
+
"""The ``stacklevel`` (for a ``warn`` called by our caller) of the first frame
|
|
79
|
+
outside http2py, so a warning points at the user's line, however deep in
|
|
80
|
+
http2py it was raised; and repeated checks of one call collapse into one."""
|
|
81
|
+
frame, level = sys._getframe(1), 1
|
|
82
|
+
while frame is not None and frame.f_code.co_filename.startswith(_PACKAGE_DIR):
|
|
83
|
+
frame, level = frame.f_back, level + 1
|
|
84
|
+
return level
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_loads(loads) -> None:
|
|
88
|
+
"""Validate a ``loads=`` / ``binary_loads=`` argument: ``None`` or a callable.
|
|
89
|
+
|
|
90
|
+
``pickle.loads`` itself gets a warning suggesting ``unsafe_pickle_loads``.
|
|
91
|
+
That is advice, not a guard: any other callable (a ``partial`` of
|
|
92
|
+
``pickle.loads``, ``dill.loads``, ...) is trusted as given.
|
|
93
|
+
"""
|
|
94
|
+
if loads is None:
|
|
95
|
+
return
|
|
96
|
+
if not callable(loads):
|
|
97
|
+
raise TypeError(f"loads must be callable, got {loads!r}")
|
|
98
|
+
if loads is pickle.loads:
|
|
99
|
+
warnings.warn(
|
|
100
|
+
"loads=pickle.loads unpickles server data, which lets a spoofed or "
|
|
101
|
+
"compromised server run code on this client. Use "
|
|
102
|
+
"loads=unsafe_pickle_loads to make that choice explicit, and only if "
|
|
103
|
+
"the server is fully trusted.",
|
|
104
|
+
stacklevel=_stacklevel_outside_package(),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _handle_resp(func, content_type, *, loads: Optional[Callable[[bytes], Any]] = None):
|
|
109
|
+
check_loads(loads)
|
|
110
|
+
|
|
111
|
+
def output_trans(resp):
|
|
112
|
+
if resp.status_code == 200:
|
|
113
|
+
if content_type == JSON_CONTENT_TYPE:
|
|
114
|
+
output = resp.json()
|
|
115
|
+
elif content_type == BINARY_CONTENT_TYPE:
|
|
116
|
+
if loads is None:
|
|
117
|
+
raise BinaryResponseNotDecoded(
|
|
118
|
+
"Got a binary response but no loads=... to decode it. "
|
|
119
|
+
"Binary responses are no longer unpickled by default, "
|
|
120
|
+
"since unpickling lets the server run code on this "
|
|
121
|
+
"client. Pass binary_loads=bytes for the raw content, a "
|
|
122
|
+
"decoder of your own, or binary_loads=unsafe_pickle_loads "
|
|
123
|
+
"(from http2py.decorators) if the server is fully trusted."
|
|
124
|
+
)
|
|
125
|
+
output = loads(resp.content)
|
|
126
|
+
elif content_type == RAW_CONTENT_TYPE:
|
|
127
|
+
output = resp.text
|
|
128
|
+
else:
|
|
129
|
+
raise NotImplementedError(
|
|
130
|
+
f"Response of type {content_type} is not supported yet."
|
|
131
|
+
)
|
|
132
|
+
return func(output)
|
|
133
|
+
else:
|
|
134
|
+
_handle_error(resp)
|
|
135
|
+
|
|
136
|
+
output_trans.content_type = content_type
|
|
137
|
+
return output_trans
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
handle_json_resp = partial(_handle_resp, content_type=JSON_CONTENT_TYPE)
|
|
141
|
+
handle_binary_resp = partial(_handle_resp, content_type=BINARY_CONTENT_TYPE)
|
|
142
|
+
handle_raw_resp = partial(_handle_resp, content_type=RAW_CONTENT_TYPE)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""A tiny worked example of the CLI-making tools, used by the docs and by hand."""
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import cw
|
|
4
4
|
from collections.abc import Iterable
|
|
5
5
|
|
|
6
6
|
from http2py.cli_maker import mk_argparse_friendly
|
|
@@ -13,6 +13,11 @@ def myfun(a: int, b: Iterable[int]):
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
if __name__ == "__main__":
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
from http2py.cli_maker import CLI_CONVENTION
|
|
17
|
+
|
|
18
|
+
raise SystemExit(
|
|
19
|
+
cw.dispatch(
|
|
20
|
+
[mk_argparse_friendly(AnnotAndDfltIoTrans()(myfun))],
|
|
21
|
+
convention=CLI_CONVENTION,
|
|
22
|
+
)
|
|
23
|
+
)
|
|
@@ -54,12 +54,19 @@ from http2py.constants import (
|
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
from http2py.util import I2mintModuleNotFoundErrorNiceMessage, is_jsonable
|
|
57
|
+
from http2py.decorators import check_loads, handle_binary_resp
|
|
57
58
|
from http2py.default_configs import (
|
|
58
59
|
default_json_output_trans,
|
|
59
60
|
default_text_output_trans,
|
|
60
|
-
|
|
61
|
+
_default_output_trans,
|
|
61
62
|
)
|
|
62
63
|
|
|
64
|
+
# Binary request bodies are pickled by default. That is safe for this client (it
|
|
65
|
+
# only serializes its own arguments), but a server must opt into unpickling them
|
|
66
|
+
# (py2http's ``handle_binary_req(..., loads=unsafe_pickle_loads)``), so pass
|
|
67
|
+
# ``binary_dumps=`` to match whatever decoder the server uses.
|
|
68
|
+
DFLT_BINARY_DUMPS = pickle.dumps
|
|
69
|
+
|
|
63
70
|
# TODO: Do we really need this anymore?
|
|
64
71
|
with I2mintModuleNotFoundErrorNiceMessage():
|
|
65
72
|
from i2.util import inject_method, imdict
|
|
@@ -130,7 +137,13 @@ def mk_param_spec_from_arg_schema(arg, required=False):
|
|
|
130
137
|
# TODO: Deprecated output_trans in favor of egress, for consistency with other i2imint?
|
|
131
138
|
# TODO: Use method_spec object that has more schema, signature, and validation.
|
|
132
139
|
def mk_request_function(
|
|
133
|
-
method_spec,
|
|
140
|
+
method_spec,
|
|
141
|
+
*,
|
|
142
|
+
function_kind="method",
|
|
143
|
+
dispatch=request,
|
|
144
|
+
verify_cert=True,
|
|
145
|
+
binary_loads=None,
|
|
146
|
+
binary_dumps=DFLT_BINARY_DUMPS,
|
|
134
147
|
):
|
|
135
148
|
"""
|
|
136
149
|
Makes function that will make http requests for you, on your own terms.
|
|
@@ -165,11 +178,19 @@ def mk_request_function(
|
|
|
165
178
|
- 'wraps': A function whose signature we should use as the output's function's signature
|
|
166
179
|
|
|
167
180
|
:param method_spec: Specification of how to convert arguments of the function that is being made to an http request.
|
|
181
|
+
:param binary_loads: How to decode a binary (octet-stream) response, when
|
|
182
|
+
``method_spec`` has no ``output_trans``. ``None`` (the default) raises
|
|
183
|
+
``BinaryResponseNotDecoded`` on such a response rather than unpickling
|
|
184
|
+
it; pass ``bytes`` for the raw content, a decoder of your own, or
|
|
185
|
+
``http2py.decorators.unsafe_pickle_loads`` if the server is fully trusted.
|
|
186
|
+
:param binary_dumps: How to encode the arguments of a binary request body
|
|
187
|
+
(default ``pickle.dumps``; see ``DFLT_BINARY_DUMPS``).
|
|
168
188
|
:return: A function.
|
|
169
189
|
Note: I say "function", but the function is meant to be a method, so the function has a self as first argument.
|
|
170
190
|
That argument is ignored.
|
|
171
191
|
|
|
172
192
|
"""
|
|
193
|
+
check_loads(binary_loads)
|
|
173
194
|
if isinstance(method_spec, str):
|
|
174
195
|
# If method_spec is a string, assume it's a url_template with a get method
|
|
175
196
|
# like 'http://myapi.com/search?q={search_term}'
|
|
@@ -202,7 +223,7 @@ def mk_request_function(
|
|
|
202
223
|
elif response_type == JSON_CONTENT_TYPE:
|
|
203
224
|
output_trans = default_json_output_trans
|
|
204
225
|
else:
|
|
205
|
-
output_trans =
|
|
226
|
+
output_trans = handle_binary_resp(_default_output_trans, loads=binary_loads)
|
|
206
227
|
|
|
207
228
|
wraps_func = method_spec.pop("wraps", None)
|
|
208
229
|
|
|
@@ -261,7 +282,7 @@ def mk_request_function(
|
|
|
261
282
|
)
|
|
262
283
|
_request_kwargs["data"] = json.dumps(json_kwargs)
|
|
263
284
|
elif content_type == BINARY_CONTENT_TYPE:
|
|
264
|
-
_request_kwargs["data"] =
|
|
285
|
+
_request_kwargs["data"] = binary_dumps(kwargs)
|
|
265
286
|
elif content_type == FORM_CONTENT_TYPE:
|
|
266
287
|
fields = json.dumps(json_kwargs).encode("utf-8")
|
|
267
288
|
binaries = {k: (f"{k}.bin", v) for k, v in binary_kwargs.items()}
|
|
@@ -396,7 +417,15 @@ class Py2Request:
|
|
|
396
417
|
self._inject_method(method_name, method_spec, method_func_from_method_spec)
|
|
397
418
|
|
|
398
419
|
def _process_method_specs(self):
|
|
399
|
-
|
|
420
|
+
# (also when it is a functools.partial of it, e.g. with binary_loads=...)
|
|
421
|
+
if (
|
|
422
|
+
getattr(
|
|
423
|
+
self._dflt_method_func_from_method_spec,
|
|
424
|
+
"func",
|
|
425
|
+
self._dflt_method_func_from_method_spec,
|
|
426
|
+
)
|
|
427
|
+
is mk_request_function
|
|
428
|
+
): # TODO: WTF!
|
|
400
429
|
|
|
401
430
|
def gen():
|
|
402
431
|
for method_name, method_spec in self._method_specs.items():
|
|
@@ -702,6 +731,8 @@ def mk_request_func_from_openapi_spec(
|
|
|
702
731
|
content_type=JSON_CONTENT_TYPE,
|
|
703
732
|
input_trans=None,
|
|
704
733
|
output_trans=None,
|
|
734
|
+
binary_loads=None,
|
|
735
|
+
binary_dumps=DFLT_BINARY_DUMPS,
|
|
705
736
|
):
|
|
706
737
|
base_url = openapi_spec["servers"][0]["url"]
|
|
707
738
|
if base_url.endswith("/"):
|
|
@@ -719,7 +750,12 @@ def mk_request_func_from_openapi_spec(
|
|
|
719
750
|
output_trans=output_trans,
|
|
720
751
|
)
|
|
721
752
|
|
|
722
|
-
func = mk_request_function(
|
|
753
|
+
func = mk_request_function(
|
|
754
|
+
method_spec,
|
|
755
|
+
function_kind="function",
|
|
756
|
+
binary_loads=binary_loads,
|
|
757
|
+
binary_dumps=binary_dumps,
|
|
758
|
+
)
|
|
723
759
|
openapi_props = glom(
|
|
724
760
|
spec, f"requestBody.content.{content_type}.schema.properties", default={}
|
|
725
761
|
)
|
|
@@ -780,10 +816,13 @@ def openapi_to_py(
|
|
|
780
816
|
path_to_func_name: Callable[[str, str], str] = default_path_to_func_name,
|
|
781
817
|
servers: list | None = None,
|
|
782
818
|
default_server_url: str = "http://localhost:8000",
|
|
819
|
+
binary_loads=None,
|
|
820
|
+
binary_dumps=DFLT_BINARY_DUMPS,
|
|
783
821
|
) -> dict[str, Callable]:
|
|
784
822
|
"""
|
|
785
823
|
Given an OpenAPI spec dict, returns a dict mapping function names to request functions.
|
|
786
824
|
The `servers` argument overrides or provides a default for the OpenAPI servers field.
|
|
825
|
+
`binary_loads` / `binary_dumps` are passed to `mk_request_function`.
|
|
787
826
|
"""
|
|
788
827
|
# Patch the spec with servers if needed
|
|
789
828
|
patched_spec = dict(openapi_spec)
|
|
@@ -799,7 +838,13 @@ def openapi_to_py(
|
|
|
799
838
|
result = {}
|
|
800
839
|
for method, uri in paths:
|
|
801
840
|
func_name = path_to_func_name(method, uri)
|
|
802
|
-
func = mk_request_func_from_openapi_spec(
|
|
841
|
+
func = mk_request_func_from_openapi_spec(
|
|
842
|
+
uri,
|
|
843
|
+
patched_spec,
|
|
844
|
+
method=method,
|
|
845
|
+
binary_loads=binary_loads,
|
|
846
|
+
binary_dumps=binary_dumps,
|
|
847
|
+
)
|
|
803
848
|
result[func_name] = func
|
|
804
849
|
return result
|
|
805
850
|
|
|
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "http2py"
|
|
9
|
-
version = "0.1.
|
|
9
|
+
version = "0.1.35"
|
|
10
10
|
description = "Tools to create python binders to http web services."
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
requires-python = ">=3.10"
|
|
@@ -34,15 +34,9 @@ dependencies = [
|
|
|
34
34
|
"i2",
|
|
35
35
|
"ju",
|
|
36
36
|
"requests",
|
|
37
|
-
"
|
|
37
|
+
"cw>=0.1.1,<0.2",
|
|
38
38
|
"PyYAML",
|
|
39
39
|
"importlib_resources",
|
|
40
|
-
# TEMPORARY, not imported by http2py: ju/oas.py has an unconditional (and
|
|
41
|
-
# unused) `import dill` while not declaring dill, so a cold `pip install
|
|
42
|
-
# http2py` dies on `import ju`. Tracked upstream as i2mint/ju#6. Remove this
|
|
43
|
-
# line once a fixed ju is released -- tests/test_dependency_workarounds.py
|
|
44
|
-
# fails as soon as that happens, so it will not be forgotten.
|
|
45
|
-
"dill",
|
|
46
40
|
]
|
|
47
41
|
|
|
48
42
|
[project.urls]
|
|
@@ -57,6 +51,11 @@ dev = [
|
|
|
57
51
|
"pytest>=7.0",
|
|
58
52
|
"pytest-cov>=4.0",
|
|
59
53
|
"ruff>=0.1.0",
|
|
54
|
+
# Not a runtime dependency of the package: `mk_api_pkg` shells out to
|
|
55
|
+
# `setup.py sdist`, so setuptools is needed to exercise it. A uv-created
|
|
56
|
+
# venv does not ship setuptools, so without this the end-to-end
|
|
57
|
+
# api_pkg_maker test would silently skip in CI.
|
|
58
|
+
"setuptools",
|
|
60
59
|
]
|
|
61
60
|
|
|
62
61
|
[tool.hatch.build.targets.wheel]
|
|
@@ -160,15 +159,16 @@ coverage_threshold = 0
|
|
|
160
159
|
coverage_report_format = [
|
|
161
160
|
"term",
|
|
162
161
|
]
|
|
163
|
-
# `http2py
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
168
|
-
#
|
|
169
|
-
#
|
|
162
|
+
# `http2py/tests` stands up a real py2http web service, so it needs a test-only
|
|
163
|
+
# dependency that CI does not install. Excluding it from collection is what keeps
|
|
164
|
+
# `pytest --doctest-modules` from aborting the session on the import.
|
|
165
|
+
# (`http2py/api_pkg_maker.py` used to be excluded here too, because
|
|
166
|
+
# `setuptools.sandbox` no longer exists and the module was unimportable. It is
|
|
167
|
+
# fixed and collected again -- see issue #14.)
|
|
168
|
+
# This path is repeated in the repo-root conftest.py so that a bare `pytest`
|
|
169
|
+
# behaves like CI -- tests/test_ci_collection_contract.py keeps the two lists
|
|
170
|
+
# in agreement.
|
|
170
171
|
exclude_paths = [
|
|
171
|
-
"http2py/api_pkg_maker.py",
|
|
172
172
|
"http2py/tests",
|
|
173
173
|
]
|
|
174
174
|
test_on_windows = false
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"recorded_from": "argh 0.31.3 via ArghParser().add_commands(...) with NO explicit name-mapping policy -- argh's legacy mode, which is what http2py.cli_maker.mk_cli used before the cw migration",
|
|
3
|
+
"note": "Normalised usage lines only: CPython rewrites --help bodies between versions and CI spans 3.10 and 3.12.",
|
|
4
|
+
"usages": {
|
|
5
|
+
"__top__": "usage: PROG [-h] {get-thing,make,plain} ...",
|
|
6
|
+
"get-thing": "usage: PROG get-thing [-h] -u UID -p PID [-v VERBOSE] [-l LIMIT] [-a API_KEY] [-c CONFIG]",
|
|
7
|
+
"make": "usage: PROG make [-h] -n NAME [--count COUNT] [-f FLAG] [-a API_KEY] [--config CONFIG]",
|
|
8
|
+
"plain": "usage: PROG plain [-h] [-a API_KEY] [-c CONFIG] [args ...]"
|
|
9
|
+
}
|
|
10
|
+
}
|