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.
Files changed (36) hide show
  1. {http2py-0.1.33 → http2py-0.1.35}/PKG-INFO +3 -3
  2. http2py-0.1.35/conftest.py +18 -0
  3. {http2py-0.1.33 → http2py-0.1.35}/http2py/api_pkg_maker.py +61 -11
  4. {http2py-0.1.33 → http2py-0.1.35}/http2py/cli_maker.py +36 -8
  5. {http2py-0.1.33 → http2py-0.1.35}/http2py/client.py +20 -1
  6. http2py-0.1.35/http2py/decorators.py +142 -0
  7. {http2py-0.1.33 → http2py-0.1.35}/http2py/example_cli.py +9 -4
  8. {http2py-0.1.33 → http2py-0.1.35}/http2py/py2request.py +52 -7
  9. {http2py-0.1.33 → http2py-0.1.35}/pyproject.toml +16 -16
  10. http2py-0.1.35/tests/cli_goldens/mk_cli.json +10 -0
  11. http2py-0.1.35/tests/spec_fixture.py +89 -0
  12. http2py-0.1.35/tests/test_binary_payloads.py +189 -0
  13. {http2py-0.1.33 → http2py-0.1.35}/tests/test_ci_collection_contract.py +95 -19
  14. http2py-0.1.35/tests/test_cli_surface.py +171 -0
  15. http2py-0.1.33/conftest.py +0 -21
  16. http2py-0.1.33/http2py/decorators.py +0 -59
  17. http2py-0.1.33/tests/test_dependency_workarounds.py +0 -85
  18. {http2py-0.1.33 → http2py-0.1.35}/.gitattributes +0 -0
  19. {http2py-0.1.33 → http2py-0.1.35}/.github/workflows/ci.yml +0 -0
  20. {http2py-0.1.33 → http2py-0.1.35}/.gitignore +0 -0
  21. {http2py-0.1.33 → http2py-0.1.35}/LICENSE +0 -0
  22. {http2py-0.1.33 → http2py-0.1.35}/README.md +0 -0
  23. {http2py-0.1.33 → http2py-0.1.35}/http2py/__init__.py +0 -0
  24. {http2py-0.1.33 → http2py-0.1.35}/http2py/authentication.py +0 -0
  25. {http2py-0.1.33 → http2py-0.1.35}/http2py/constants.py +0 -0
  26. {http2py-0.1.33 → http2py-0.1.35}/http2py/default_configs.py +0 -0
  27. {http2py-0.1.33 → http2py-0.1.35}/http2py/global_state.py +0 -0
  28. {http2py-0.1.33 → http2py-0.1.35}/http2py/http2py_cls.py +0 -0
  29. {http2py-0.1.33 → http2py-0.1.35}/http2py/resources/module_maker/func_tpl.txt +0 -0
  30. {http2py-0.1.33 → http2py-0.1.35}/http2py/testing_utils.py +0 -0
  31. {http2py-0.1.33 → http2py-0.1.35}/http2py/tests/__init__.py +0 -0
  32. {http2py-0.1.33 → http2py-0.1.35}/http2py/tests/api_pkg_maker_test.py +0 -0
  33. {http2py-0.1.33 → http2py-0.1.35}/http2py/tests/conftest.py +0 -0
  34. {http2py-0.1.33 → http2py-0.1.35}/http2py/tests/test_requirements.txt +0 -0
  35. {http2py-0.1.33 → http2py-0.1.35}/http2py/util.py +0 -0
  36. {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.33
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: argh
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
- Note: this module imports ``setuptools.sandbox``, which modern setuptools no
7
- longer provides, so importing it raises ``ImportError``. It is kept in place
8
- pending a decision (rewrite or remove) and is excluded from test collection;
9
- see the repo-root ``conftest.py``.
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
- OUTPUT_DIR = os.path.join(os.environ["HOME"], "http2py", "api_pkgs")
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
- os.chdir(tempdir)
152
- sandbox.run_setup("setup.py", ["sdist"])
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
- argh.dispatch_command(mk_api_pkg)
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 dispatched with ``argh`` by :func:`mk_cli` / :func:`dispatch_cli`.
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 argh
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
- parser = argh.ArghParser()
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 dispatches it (see documentation of mk_cli)"""
119
- parser = mk_cli(*args, **kwargs)
120
- parser.dispatch()
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, dispatch=self.handle_request, verify_cert=self.verify_cert
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 argh
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
- parser = argh.ArghParser()
17
- parser.add_commands([mk_argparse_friendly(AnnotAndDfltIoTrans()(myfun))])
18
- parser.dispatch()
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
- default_binary_output_trans,
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, *, function_kind="method", dispatch=request, verify_cert=True
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 = default_binary_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"] = pickle.dumps(kwargs)
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
- if self._dflt_method_func_from_method_spec == mk_request_function: # TODO: WTF!
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(method_spec, function_kind="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(uri, patched_spec, method=method)
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.33"
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
- "argh",
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.api_pkg_maker` imports `setuptools.sandbox`, which modern setuptools
164
- # no longer ships, so the module raises ImportError on import and would abort
165
- # the whole collection. Whether to rewrite it or drop it is still open on
166
- # issue #14; excluding it from collection is the reversible middle course.
167
- # The same two paths are repeated in the repo-root conftest.py so that a bare
168
- # `pytest` behaves like CI -- tests/test_ci_collection_contract.py keeps the
169
- # two lists in agreement.
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
+ }