http2py 0.1.35__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.
Files changed (36) hide show
  1. {http2py-0.1.35 → http2py-0.1.36}/PKG-INFO +1 -1
  2. http2py-0.1.36/http2py/api_pkg_maker.py +360 -0
  3. {http2py-0.1.35 → http2py-0.1.36}/http2py/cli_maker.py +20 -2
  4. {http2py-0.1.35 → http2py-0.1.36}/http2py/decorators.py +2 -0
  5. {http2py-0.1.35 → http2py-0.1.36}/http2py/tests/api_pkg_maker_test.py +4 -2
  6. {http2py-0.1.35 → http2py-0.1.36}/pyproject.toml +1 -1
  7. http2py-0.1.36/tests/test_error_statuses.py +25 -0
  8. http2py-0.1.36/tests/test_generated_clients.py +184 -0
  9. http2py-0.1.35/http2py/api_pkg_maker.py +0 -219
  10. {http2py-0.1.35 → http2py-0.1.36}/.gitattributes +0 -0
  11. {http2py-0.1.35 → http2py-0.1.36}/.github/workflows/ci.yml +0 -0
  12. {http2py-0.1.35 → http2py-0.1.36}/.gitignore +0 -0
  13. {http2py-0.1.35 → http2py-0.1.36}/LICENSE +0 -0
  14. {http2py-0.1.35 → http2py-0.1.36}/README.md +0 -0
  15. {http2py-0.1.35 → http2py-0.1.36}/conftest.py +0 -0
  16. {http2py-0.1.35 → http2py-0.1.36}/http2py/__init__.py +0 -0
  17. {http2py-0.1.35 → http2py-0.1.36}/http2py/authentication.py +0 -0
  18. {http2py-0.1.35 → http2py-0.1.36}/http2py/client.py +0 -0
  19. {http2py-0.1.35 → http2py-0.1.36}/http2py/constants.py +0 -0
  20. {http2py-0.1.35 → http2py-0.1.36}/http2py/default_configs.py +0 -0
  21. {http2py-0.1.35 → http2py-0.1.36}/http2py/example_cli.py +0 -0
  22. {http2py-0.1.35 → http2py-0.1.36}/http2py/global_state.py +0 -0
  23. {http2py-0.1.35 → http2py-0.1.36}/http2py/http2py_cls.py +0 -0
  24. {http2py-0.1.35 → http2py-0.1.36}/http2py/py2request.py +0 -0
  25. {http2py-0.1.35 → http2py-0.1.36}/http2py/resources/module_maker/func_tpl.txt +0 -0
  26. {http2py-0.1.35 → http2py-0.1.36}/http2py/testing_utils.py +0 -0
  27. {http2py-0.1.35 → http2py-0.1.36}/http2py/tests/__init__.py +0 -0
  28. {http2py-0.1.35 → http2py-0.1.36}/http2py/tests/conftest.py +0 -0
  29. {http2py-0.1.35 → http2py-0.1.36}/http2py/tests/test_requirements.txt +0 -0
  30. {http2py-0.1.35 → http2py-0.1.36}/http2py/util.py +0 -0
  31. {http2py-0.1.35 → http2py-0.1.36}/tests/cli_goldens/mk_cli.json +0 -0
  32. {http2py-0.1.35 → http2py-0.1.36}/tests/spec_fixture.py +0 -0
  33. {http2py-0.1.35 → http2py-0.1.36}/tests/test_binary_payloads.py +0 -0
  34. {http2py-0.1.35 → http2py-0.1.36}/tests/test_ci_collection_contract.py +0 -0
  35. {http2py-0.1.35 → http2py-0.1.36}/tests/test_cli_surface.py +0 -0
  36. {http2py-0.1.35 → 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.35
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
@@ -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(openapi_spec, method, expected_auth_kwargs)
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)
@@ -48,6 +48,8 @@ def _handle_error(resp):
48
48
  raise InputError(resp.text)
49
49
  if resp.reason == "DuplicateRecordError":
50
50
  raise DuplicateRecordError(resp.text)
51
+ if resp.status_code == 401: # py2http answers AuthorizationError with 401
52
+ raise AuthorizationError(resp.text)
51
53
  if resp.status_code == 403:
52
54
  raise ForbiddenError(resp.text)
53
55
  if resp.status_code == 404:
@@ -1,7 +1,7 @@
1
1
  import os
2
2
  import re
3
3
  import pytest
4
- from http2py.api_pkg_maker import OUTPUT_DIR, mk_api_pkg
4
+ from http2py.api_pkg_maker import mk_api_pkg
5
5
  from http2py.constants import JSON_CONTENT_TYPE
6
6
 
7
7
 
@@ -131,6 +131,7 @@ from http2py.constants import JSON_CONTENT_TYPE
131
131
  ],
132
132
  )
133
133
  def test_mk_api_pkg(
134
+ tmp_path,
134
135
  openapi_spec,
135
136
  openapi_url,
136
137
  pkg_name,
@@ -147,9 +148,10 @@ def test_mk_api_pkg(
147
148
  pkg_description,
148
149
  pkg_author,
149
150
  pkg_license,
151
+ output_dir=str(tmp_path),
150
152
  )
151
153
  output_pkg_dirpath = os.path.dirname(output_pkg_filepath)
152
- assert output_pkg_dirpath == OUTPUT_DIR
154
+ assert output_pkg_dirpath == str(tmp_path)
153
155
  output_pkg_filename = os.path.basename(output_pkg_filepath)
154
156
  output_pkg_name = re.search(r"(.+?)-", output_pkg_filename).group(1)
155
157
  if pkg_name:
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "http2py"
9
- version = "0.1.35"
9
+ version = "0.1.36"
10
10
  description = "Tools to create python binders to http web services."
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
@@ -0,0 +1,25 @@
1
+ """Server errors come back as the i2 error the server raised."""
2
+
3
+ from types import SimpleNamespace
4
+
5
+ import pytest
6
+ from i2.errors import AuthorizationError, ForbiddenError, InputError, NotFoundError
7
+
8
+ from http2py.decorators import _handle_error
9
+
10
+
11
+ @pytest.mark.parametrize(
12
+ "status, reason, error",
13
+ [
14
+ (401, "Unauthorized", AuthorizationError), # py2http since i2mint/py2http#31
15
+ (400, "AuthorizationError", AuthorizationError), # older py2http
16
+ (400, "InputError", InputError),
17
+ (403, "Forbidden", ForbiddenError),
18
+ (404, "Not Found", NotFoundError),
19
+ (500, "Internal Server Error", RuntimeError),
20
+ ],
21
+ )
22
+ def test_status_maps_to_error(status, reason, error):
23
+ resp = SimpleNamespace(status_code=status, reason=reason, text="msg")
24
+ with pytest.raises(error):
25
+ _handle_error(resp)
@@ -0,0 +1,184 @@
1
+ """``mk_cli`` and ``api_pkg_maker`` pass ``binary_loads`` through, and a package
2
+ generated from a spec compiles and cannot be made to run code by that spec
3
+ (i2mint/http2py#21)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import copy
8
+ import importlib
9
+ import json
10
+ import sys
11
+ import tarfile
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ sys.path.insert(0, str(Path(__file__).parent))
17
+
18
+ from spec_fixture import SPEC # noqa: E402
19
+
20
+ from http2py.api_pkg_maker import api_pkg_files, mk_api_pkg, object_ref # noqa: E402
21
+
22
+
23
+ def _write(files: dict, root: Path) -> None:
24
+ for relpath, content in files.items():
25
+ path = root / relpath
26
+ path.parent.mkdir(parents=True, exist_ok=True)
27
+ path.write_text(content, encoding="utf-8")
28
+
29
+
30
+ @pytest.fixture
31
+ def import_generated(tmp_path, monkeypatch):
32
+ """Write a generated package under tmp_path and import it."""
33
+
34
+ def _import(files):
35
+ _write(files, tmp_path)
36
+ monkeypatch.syspath_prepend(str(tmp_path))
37
+ pkg_name = next(path.split("/")[0] for path in files if "/" in path)
38
+ importlib.invalidate_caches()
39
+ module = importlib.import_module(f"{pkg_name}.funcs")
40
+ monkeypatch.delitem(sys.modules, pkg_name)
41
+ monkeypatch.delitem(sys.modules, f"{pkg_name}.funcs")
42
+ return module
43
+
44
+ return _import
45
+
46
+
47
+ def test_generated_package_compiles_and_exposes_every_route(import_generated):
48
+ files = api_pkg_files(SPEC, pkg_name="genpkg_routes")
49
+ for relpath, content in files.items():
50
+ if relpath.endswith(".py"):
51
+ compile(content, relpath, "exec")
52
+ funcs = import_generated(files)
53
+ assert {"get_thing", "make", "plain"} <= set(vars(funcs))
54
+ assert funcs.api.binary_loads is None
55
+
56
+
57
+ def test_spec_is_data_not_source():
58
+ spec = copy.deepcopy(SPEC)
59
+ spec["info"]["title"] = "'''\"\"\"); import os; os.system('x') #"
60
+ files = api_pkg_files(spec, pkg_name="genpkg_data")
61
+ assert "os.system" not in files["genpkg_data/funcs.py"]
62
+ assert json.loads(files["genpkg_data/openapi_spec.json"]) == spec
63
+
64
+
65
+ @pytest.mark.parametrize(
66
+ "bad_name",
67
+ [
68
+ "x():\n import os\n os.system('x')\ndef y",
69
+ "class",
70
+ "a-b",
71
+ "\uff41\uff50\uff49", # full-width "api": an identifier Python normalizes to api
72
+ "api",
73
+ "_apply_sig",
74
+ "funcs",
75
+ ],
76
+ )
77
+ def test_route_name_that_is_not_an_identifier_is_refused(bad_name):
78
+ spec = copy.deepcopy(SPEC)
79
+ spec["paths"]["/plain"]["get"]["x-method_name"] = bad_name
80
+ with pytest.raises(ValueError, match="not an ASCII Python identifier|reserved"):
81
+ api_pkg_files(spec, pkg_name="genpkg_bad")
82
+
83
+
84
+ def test_setup_cfg_fields_stay_on_one_line():
85
+ files = api_pkg_files(
86
+ SPEC, pkg_name="genpkg_cfg", pkg_description="one\n[options]\nscripts = x"
87
+ )
88
+ assert "\n[options]\nscripts" not in files["setup.cfg"]
89
+
90
+
91
+ def test_binary_loads_reaches_the_generated_client(import_generated):
92
+ files = api_pkg_files(SPEC, pkg_name="genpkg_loads", binary_loads=bytes)
93
+ assert import_generated(files).api.binary_loads is bytes
94
+
95
+
96
+ @pytest.mark.parametrize(
97
+ "ref",
98
+ [
99
+ lambda b: b,
100
+ "bytes",
101
+ "os.system('x') or bytes",
102
+ "a.b; import os",
103
+ "json.no_such_loads",
104
+ "json.decoder", # a module, not a callable
105
+ ],
106
+ )
107
+ def test_binary_loads_must_be_an_importable_path(ref):
108
+ with pytest.raises(ValueError):
109
+ object_ref(ref)
110
+
111
+
112
+ def test_mk_api_pkg_ships_the_spec(tmp_path):
113
+ pytest.importorskip("setuptools")
114
+ sdist = mk_api_pkg(SPEC, pkg_name="genpkg_sdist", output_dir=str(tmp_path))
115
+ with tarfile.open(sdist) as tar:
116
+ names = tar.getnames()
117
+ assert "genpkg_sdist-1.0.0/genpkg_sdist/openapi_spec.json" in names
118
+
119
+
120
+ def test_dispatch_cli_passes_binary_loads_to_the_client(monkeypatch, tmp_path):
121
+ from http2py import cli_maker
122
+
123
+ seen = {}
124
+
125
+ class FakeClient:
126
+ def __init__(self, openapi_spec, **kwargs):
127
+ seen.update(kwargs)
128
+
129
+ def plain(self, **kwargs):
130
+ return "ran"
131
+
132
+ real_mk_cli = cli_maker.mk_cli
133
+
134
+ def mk_cli_then_fake_client(*args, **kwargs):
135
+ parser = real_mk_cli(*args, **kwargs) # built from the real client
136
+ monkeypatch.setattr(cli_maker, "HttpClient", FakeClient) # run with the fake
137
+ return parser
138
+
139
+ monkeypatch.setattr(cli_maker, "mk_cli", mk_cli_then_fake_client)
140
+ monkeypatch.setattr(sys, "argv", ["PROG", "plain", "--api-key", "k"])
141
+ config = tmp_path / "credentials.json"
142
+ config.write_text("{}")
143
+ cli_maker.dispatch_cli(
144
+ openapi_spec=SPEC, config_filename=str(config), binary_loads=bytes
145
+ )
146
+ assert seen["binary_loads"] is bytes
147
+
148
+
149
+ def test_mk_cli_refuses_a_non_callable_binary_loads():
150
+ from http2py.cli_maker import mk_cli
151
+
152
+ with pytest.raises(TypeError):
153
+ mk_cli(openapi_spec=SPEC, binary_loads="bytes")
154
+
155
+
156
+ def test_spec_without_routes_is_refused():
157
+ spec = copy.deepcopy(SPEC)
158
+ spec["paths"] = {"/x": {}}
159
+ with pytest.raises(RuntimeError, match="empty"):
160
+ api_pkg_files(spec, pkg_name="genpkg_empty")
161
+
162
+
163
+ def test_spec_may_be_given_as_json_text(import_generated):
164
+ files = api_pkg_files(json.dumps(SPEC), pkg_name="genpkg_text")
165
+ assert "plain" in vars(import_generated(files))
166
+
167
+
168
+ def test_awkward_names_and_values_still_build(tmp_path):
169
+ pytest.importorskip("setuptools")
170
+ spec = copy.deepcopy(SPEC)
171
+ spec["servers"] = [{"url": "https://h/a%20b"}]
172
+ sdist = mk_api_pkg(
173
+ spec,
174
+ pkg_name="MyPkg",
175
+ pkg_version="v1.0",
176
+ pkg_author="100% me",
177
+ output_dir=str(tmp_path),
178
+ )
179
+ assert Path(sdist).is_file()
180
+
181
+
182
+ def test_binary_loads_ref_to_a_module_not_installed_here_is_kept():
183
+ with pytest.warns(UserWarning, match="cannot be imported here"):
184
+ assert object_ref("no_such_module.loads") == "no_such_module.loads"
@@ -1,219 +0,0 @@
1
- """Generate an installable python package from an OpenAPI specification.
2
-
3
- Given a spec (or the URL of one), :func:`mk_api_pkg` writes a small source
4
- distribution whose functions are bound to the service's routes.
5
-
6
- The sdist is built by running ``setup.py sdist`` in a subprocess. This module
7
- used to call ``setuptools.sandbox.run_setup``, which modern setuptools no longer
8
- ships -- so merely *importing* it raised ``ImportError`` and the
9
- ``api-pkg-maker`` console script could not start at all (i2mint/http2py#14).
10
- The subprocess is also better behaved: ``sandbox.run_setup`` needed the process
11
- to ``os.chdir`` into the build directory and never came back.
12
-
13
- ``setuptools`` is still needed to *run* :func:`mk_api_pkg`, but it is
14
- deliberately NOT a declared dependency of http2py: it is needed by this one
15
- function, not by ``import http2py``, and a virtualenv created by ``uv`` does not
16
- ship it. :func:`_check_build_backend` turns its absence into an actionable
17
- message instead of a subprocess exit code.
18
- """
19
-
20
- import os
21
- import shutil
22
- import subprocess
23
- import sys
24
- import tempfile
25
- from http2py.client import HttpClient
26
- from datetime import datetime, timezone
27
-
28
- #: Where built packages are written. ``expanduser`` rather than
29
- #: ``os.environ["HOME"]``: the latter is a module-import-time ``KeyError`` on
30
- #: Windows, which would take the whole package down with it.
31
- OUTPUT_DIR = os.path.join(os.path.expanduser("~"), "http2py", "api_pkgs")
32
-
33
- INIT_FILE_TPL = """from .funcs import {funcs}
34
- """
35
-
36
- FUNCS_FILE_TPL = """from i2 import Sig
37
- from http2py.client import HttpClient
38
-
39
- api = HttpClient(openapi_spec={openapi_spec}, url={openapi_url})
40
-
41
-
42
- def apply_sig(meth):
43
- def wrapper(func):
44
- sig = Sig(meth) - 'self'
45
- return sig(func)
46
- return wrapper
47
- {funcs}"""
48
-
49
- FUNC_TPL = """
50
-
51
- @apply_sig(api.{func_name})
52
- def {func_name}(*args, **kwargs):
53
- return api.{func_name}(*args, **kwargs)
54
- """
55
-
56
- SETUP_PY = """from setuptools import setup
57
-
58
- setup()
59
- """
60
-
61
- SETUP_CFG_TPL = """[metadata]
62
- name = {name}
63
- version = {version}
64
- platforms = any
65
- description = {description}
66
- author = {author}
67
- license = {license}
68
-
69
- [options]
70
- packages = find:
71
- include_package_data = True
72
- zip_safe = False
73
- install_requires =
74
- i2
75
- http2py
76
- """
77
-
78
-
79
- def mk_api_pkg(
80
- openapi_spec: dict = None,
81
- openapi_url: str = None,
82
- pkg_name: str = None,
83
- pkg_version: str = None,
84
- pkg_description: str = None,
85
- pkg_author: str = None,
86
- pkg_license: str = None,
87
- ):
88
- """
89
- Build a pip installable package containing the functions to consume a running
90
- webservice application executed with py2http.
91
-
92
- :param openapi_spec: The openapi specification for the webservice.
93
- :type openapi_spec: dict, optional
94
- :param openapi_url: The url to fetch the openapi specification for the webservice.
95
- :type openapi_url: str, optional
96
- :param pkg_name: The name of the package. If no name is provided, default to
97
- 'apipkg{timestamp}'
98
- :type pkg_name: str, optional
99
- :param pkg_version: The version number of the package. If no version is provided,
100
- default to 1.0.0
101
- :type pkg_version: str, optional
102
- :param pkg_description: The description of the package.
103
- :type pkg_description: str, optional
104
- :param pkg_author: The author of the package. If no author provided, default to
105
- 'API Package Maker'
106
- :type pkg_author: str, optional
107
- :param pkg_license: The license for the package. If no license provided, default
108
- to 'Apache'
109
- :type pkg_license: str, optional
110
- """
111
-
112
- def mk_str_element(value: str):
113
- return value if value is None else f"'{value}'"
114
-
115
- def mk_pkg_name():
116
- dt = datetime.now(timezone.utc)
117
- utc_time = dt.replace(tzinfo=timezone.utc)
118
- utc_timestamp = int(utc_time.timestamp())
119
- return f"apipkg{utc_timestamp}"
120
-
121
- def create_file(filepath, content):
122
- with open(filepath, "w") as file:
123
- print(content, file=file)
124
-
125
- _check_build_backend()
126
-
127
- tempdir = tempfile.mkdtemp()
128
- try:
129
- api = HttpClient(openapi_spec=openapi_spec, url=openapi_url)
130
- paths_spec = api.openapi_spec.get("paths")
131
- if not paths_spec:
132
- raise RuntimeError("The API is empty")
133
- func_names = [path[1:] for path in paths_spec]
134
- funcs_code = [FUNC_TPL.format(func_name=func_name) for func_name in func_names]
135
- funcs_file_code = FUNCS_FILE_TPL.format(
136
- openapi_spec=mk_str_element(openapi_spec),
137
- openapi_url=mk_str_element(openapi_url),
138
- funcs="".join(funcs_code),
139
- )
140
- init_file_code = INIT_FILE_TPL.format(funcs=", ".join(func_names))
141
- pkg_name = pkg_name or mk_pkg_name()
142
- module_dir = os.path.join(tempdir, pkg_name)
143
- os.makedirs(module_dir)
144
- create_file(
145
- filepath=os.path.join(module_dir, f"__init__.py"), content=init_file_code
146
- )
147
- create_file(
148
- filepath=os.path.join(module_dir, f"funcs.py"), content=funcs_file_code
149
- )
150
- server_url = api.openapi_spec["servers"][0]["url"]
151
- pkg_version = pkg_version or "1.0.0"
152
- setup_cfg_content = SETUP_CFG_TPL.format(
153
- name=pkg_name,
154
- version=pkg_version,
155
- description=pkg_description
156
- or f"A client API to consume the webservices exposed at {server_url}",
157
- author=pkg_author or "API Package Maker",
158
- license=pkg_license or "Apache",
159
- )
160
- create_file(
161
- filepath=os.path.join(tempdir, f"setup.cfg"), content=setup_cfg_content
162
- )
163
- create_file(filepath=os.path.join(tempdir, f"setup.py"), content=SETUP_PY)
164
- build = subprocess.run(
165
- [sys.executable, "setup.py", "sdist"],
166
- cwd=tempdir,
167
- capture_output=True,
168
- text=True,
169
- )
170
- if build.returncode != 0:
171
- # check=True would raise a CalledProcessError carrying only the exit
172
- # status, discarding the captured output that says what went wrong.
173
- raise RuntimeError(
174
- f"`setup.py sdist` failed (exit {build.returncode}) while building "
175
- f"{pkg_name!r}.\n--- stdout ---\n{build.stdout}"
176
- f"\n--- stderr ---\n{build.stderr}"
177
- )
178
- pkg_filename = f"{pkg_name}-{pkg_version}.tar.gz"
179
- if not os.path.exists(OUTPUT_DIR):
180
- os.makedirs(OUTPUT_DIR)
181
- shutil.copy(src=os.path.join(tempdir, "dist", pkg_filename), dst=OUTPUT_DIR)
182
-
183
- return os.path.join(OUTPUT_DIR, pkg_filename)
184
-
185
- finally:
186
- shutil.rmtree(tempdir)
187
-
188
-
189
- def _check_build_backend():
190
- """Fail early and legibly when the interpreter cannot build an sdist.
191
-
192
- ``setup.py sdist`` needs setuptools in the *subprocess's* interpreter, which
193
- is this one. Environments created by ``uv`` (and ``python -m venv
194
- --without-pip``) do not have it. Without this check the user sees a bare
195
- ``CalledProcessError`` with an exit status and no cause.
196
- """
197
- import importlib.util
198
-
199
- if importlib.util.find_spec("setuptools") is None:
200
- raise RuntimeError(
201
- "mk_api_pkg builds a source distribution with `setup.py sdist`, "
202
- f"which needs setuptools in {sys.executable}. Install it with: "
203
- f"{sys.executable} -m pip install setuptools"
204
- )
205
-
206
-
207
- def main():
208
- """Entry point for the ``api-pkg-maker`` console script; returns the exit code.
209
-
210
- ``cw.dispatch`` returns the code rather than exiting, and
211
- ``[project.scripts]`` wraps this in ``sys.exit(main())``.
212
- """
213
- import cw
214
-
215
- return cw.dispatch(mk_api_pkg)
216
-
217
-
218
- if __name__ == "__main__":
219
- raise SystemExit(main())
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes