http2py 0.1.33__tar.gz → 0.1.34__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 (34) hide show
  1. {http2py-0.1.33 → http2py-0.1.34}/PKG-INFO +3 -2
  2. http2py-0.1.34/conftest.py +18 -0
  3. {http2py-0.1.33 → http2py-0.1.34}/http2py/api_pkg_maker.py +61 -11
  4. {http2py-0.1.33 → http2py-0.1.34}/http2py/cli_maker.py +36 -8
  5. {http2py-0.1.33 → http2py-0.1.34}/http2py/example_cli.py +9 -4
  6. {http2py-0.1.33 → http2py-0.1.34}/pyproject.toml +16 -10
  7. http2py-0.1.34/tests/cli_goldens/mk_cli.json +10 -0
  8. http2py-0.1.34/tests/spec_fixture.py +89 -0
  9. {http2py-0.1.33 → http2py-0.1.34}/tests/test_ci_collection_contract.py +95 -19
  10. http2py-0.1.34/tests/test_cli_surface.py +171 -0
  11. http2py-0.1.33/conftest.py +0 -21
  12. {http2py-0.1.33 → http2py-0.1.34}/.gitattributes +0 -0
  13. {http2py-0.1.33 → http2py-0.1.34}/.github/workflows/ci.yml +0 -0
  14. {http2py-0.1.33 → http2py-0.1.34}/.gitignore +0 -0
  15. {http2py-0.1.33 → http2py-0.1.34}/LICENSE +0 -0
  16. {http2py-0.1.33 → http2py-0.1.34}/README.md +0 -0
  17. {http2py-0.1.33 → http2py-0.1.34}/http2py/__init__.py +0 -0
  18. {http2py-0.1.33 → http2py-0.1.34}/http2py/authentication.py +0 -0
  19. {http2py-0.1.33 → http2py-0.1.34}/http2py/client.py +0 -0
  20. {http2py-0.1.33 → http2py-0.1.34}/http2py/constants.py +0 -0
  21. {http2py-0.1.33 → http2py-0.1.34}/http2py/decorators.py +0 -0
  22. {http2py-0.1.33 → http2py-0.1.34}/http2py/default_configs.py +0 -0
  23. {http2py-0.1.33 → http2py-0.1.34}/http2py/global_state.py +0 -0
  24. {http2py-0.1.33 → http2py-0.1.34}/http2py/http2py_cls.py +0 -0
  25. {http2py-0.1.33 → http2py-0.1.34}/http2py/py2request.py +0 -0
  26. {http2py-0.1.33 → http2py-0.1.34}/http2py/resources/module_maker/func_tpl.txt +0 -0
  27. {http2py-0.1.33 → http2py-0.1.34}/http2py/testing_utils.py +0 -0
  28. {http2py-0.1.33 → http2py-0.1.34}/http2py/tests/__init__.py +0 -0
  29. {http2py-0.1.33 → http2py-0.1.34}/http2py/tests/api_pkg_maker_test.py +0 -0
  30. {http2py-0.1.33 → http2py-0.1.34}/http2py/tests/conftest.py +0 -0
  31. {http2py-0.1.33 → http2py-0.1.34}/http2py/tests/test_requirements.txt +0 -0
  32. {http2py-0.1.33 → http2py-0.1.34}/http2py/util.py +0 -0
  33. {http2py-0.1.33 → http2py-0.1.34}/tests/test_dependency_workarounds.py +0 -0
  34. {http2py-0.1.33 → http2py-0.1.34}/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.34
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,7 +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
19
+ Requires-Dist: cw<0.2,>=0.1.1
20
20
  Requires-Dist: dill
21
21
  Requires-Dist: glom
22
22
  Requires-Dist: i2
@@ -28,6 +28,7 @@ Provides-Extra: dev
28
28
  Requires-Dist: pytest-cov>=4.0; extra == 'dev'
29
29
  Requires-Dist: pytest>=7.0; extra == 'dev'
30
30
  Requires-Dist: ruff>=0.1.0; extra == 'dev'
31
+ Requires-Dist: setuptools; extra == 'dev'
31
32
  Description-Content-Type: text/markdown
32
33
 
33
34
  **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))
@@ -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
+ )
@@ -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.34"
10
10
  description = "Tools to create python binders to http web services."
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
@@ -34,7 +34,7 @@ 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
40
  # TEMPORARY, not imported by http2py: ju/oas.py has an unconditional (and
@@ -57,6 +57,11 @@ dev = [
57
57
  "pytest>=7.0",
58
58
  "pytest-cov>=4.0",
59
59
  "ruff>=0.1.0",
60
+ # Not a runtime dependency of the package: `mk_api_pkg` shells out to
61
+ # `setup.py sdist`, so setuptools is needed to exercise it. A uv-created
62
+ # venv does not ship setuptools, so without this the end-to-end
63
+ # api_pkg_maker test would silently skip in CI.
64
+ "setuptools",
60
65
  ]
61
66
 
62
67
  [tool.hatch.build.targets.wheel]
@@ -160,15 +165,16 @@ coverage_threshold = 0
160
165
  coverage_report_format = [
161
166
  "term",
162
167
  ]
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.
168
+ # `http2py/tests` stands up a real py2http web service, so it needs a test-only
169
+ # dependency that CI does not install. Excluding it from collection is what keeps
170
+ # `pytest --doctest-modules` from aborting the session on the import.
171
+ # (`http2py/api_pkg_maker.py` used to be excluded here too, because
172
+ # `setuptools.sandbox` no longer exists and the module was unimportable. It is
173
+ # fixed and collected again -- see issue #14.)
174
+ # This path is repeated in the repo-root conftest.py so that a bare `pytest`
175
+ # behaves like CI -- tests/test_ci_collection_contract.py keeps the two lists
176
+ # in agreement.
170
177
  exclude_paths = [
171
- "http2py/api_pkg_maker.py",
172
178
  "http2py/tests",
173
179
  ]
174
180
  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
+ }
@@ -0,0 +1,89 @@
1
+ """One OpenAPI spec exercising every argument shape ``mk_cli`` has to handle.
2
+
3
+ Path parameters (required, and typed), query parameters (optional, with and
4
+ without a schema default), a JSON request body with a ``required`` list, an
5
+ ``apiKey`` security scheme (which adds an ``api_key`` CLI argument), and a route
6
+ with no arguments at all (which yields a ``*args``/``**kwargs`` signature).
7
+
8
+ Kept as a module rather than inline so the golden recorder and the tests are
9
+ provably looking at the same spec.
10
+ """
11
+
12
+ CONTENT = "application/json"
13
+
14
+ SPEC = {
15
+ "openapi": "3.0.2",
16
+ "info": {"title": "example", "version": "0.1"},
17
+ "servers": [{"url": "https://example.com"}],
18
+ "security": {"apiKey": {}},
19
+ "paths": {
20
+ "/u/{uid}/p/{pid}": {
21
+ "get": {
22
+ "x-method_name": "get_thing",
23
+ "description": "Two path args and two query args.",
24
+ "parameters": [
25
+ {
26
+ "name": "uid",
27
+ "in": "path",
28
+ "required": True,
29
+ "schema": {"type": "string"},
30
+ },
31
+ {
32
+ "name": "pid",
33
+ "in": "path",
34
+ "required": True,
35
+ "schema": {"type": "integer"},
36
+ },
37
+ {
38
+ "name": "verbose",
39
+ "in": "query",
40
+ "required": False,
41
+ "schema": {"type": "boolean"},
42
+ },
43
+ {
44
+ "name": "limit",
45
+ "in": "query",
46
+ "required": False,
47
+ "schema": {"type": "integer", "default": 10},
48
+ },
49
+ ],
50
+ "responses": {
51
+ "200": {"description": "", "content": {CONTENT: {"schema": {}}}}
52
+ },
53
+ }
54
+ },
55
+ "/make": {
56
+ "post": {
57
+ "x-method_name": "make",
58
+ "description": "A request body with one required property.",
59
+ "requestBody": {
60
+ "content": {
61
+ CONTENT: {
62
+ "schema": {
63
+ "type": "object",
64
+ "properties": {
65
+ "name": {"type": "string"},
66
+ "count": {"type": "integer"},
67
+ "flag": {"type": "boolean"},
68
+ },
69
+ "required": ["name"],
70
+ }
71
+ }
72
+ }
73
+ },
74
+ "responses": {
75
+ "200": {"description": "", "content": {CONTENT: {"schema": {}}}}
76
+ },
77
+ }
78
+ },
79
+ "/plain": {
80
+ "get": {
81
+ "x-method_name": "plain",
82
+ "description": "No arguments at all.",
83
+ "responses": {
84
+ "200": {"description": "", "content": {CONTENT: {"schema": {}}}}
85
+ },
86
+ }
87
+ },
88
+ },
89
+ }
@@ -2,15 +2,16 @@
2
2
 
3
3
  Under ``pytest --doctest-modules`` (what the CI test action runs) an unimportable
4
4
  module is not one red test, it is a collection abort that takes the whole session
5
- down. Two modules in this repo are in that state:
5
+ down.
6
6
 
7
- * ``http2py/api_pkg_maker.py`` imports ``setuptools.sandbox``, removed from
8
- modern setuptools;
9
- * ``http2py/tests/api_pkg_maker_test.py`` imports that module.
7
+ ``http2py/api_pkg_maker.py`` used to be in that state -- it imported
8
+ ``setuptools.sandbox``, removed from modern setuptools. It now builds the sdist
9
+ in a subprocess instead, imports cleanly, and is collected again. What remains
10
+ excluded is ``http2py/tests``, whose fixtures stand up a real ``py2http`` web
11
+ service: ``py2http`` is a test-only requirement that CI does not install.
10
12
 
11
- Both are left in place on purpose (rewriting vs. deleting ``api_pkg_maker`` is
12
- still open on issue #14) and excluded from collection in two places that must
13
- agree: ``[tool.wads.ci.testing].exclude_paths`` in pyproject.toml, and
13
+ The exclusion lives in two places that must agree:
14
+ ``[tool.wads.ci.testing].exclude_paths`` in pyproject.toml, and
14
15
  ``collect_ignore`` in the repo-root conftest.py.
15
16
 
16
17
  There is a third, subtler trap that these tests pin down: pytest eagerly imports
@@ -31,10 +32,11 @@ import pytest
31
32
  REPO_ROOT = Path(__file__).resolve().parent.parent
32
33
 
33
34
  # Modules that are known-unimportable and therefore excluded from collection.
34
- # Keep this in step with pyproject's exclude_paths and conftest's collect_ignore.
35
- KNOWN_UNIMPORTABLE = {"http2py.api_pkg_maker"}
35
+ # Empty, and it should stay that way: an unimportable module aborts the whole
36
+ # CI session rather than failing one test.
37
+ KNOWN_UNIMPORTABLE: set = set()
36
38
 
37
- EXPECTED_EXCLUDED_PATHS = {"http2py/api_pkg_maker.py", "http2py/tests"}
39
+ EXPECTED_EXCLUDED_PATHS = {"http2py/tests"}
38
40
 
39
41
 
40
42
  def _package_modules():
@@ -62,17 +64,27 @@ def test_every_package_module_imports_except_the_known_broken_one():
62
64
  assert not failures, f"modules that would abort collection: {failures}"
63
65
 
64
66
 
65
- def test_known_broken_module_is_still_broken():
66
- """Tripwire for issue #14.
67
+ def test_api_pkg_maker_imports_and_the_console_script_can_start():
68
+ """The former tripwire for issue #14, inverted now that the module is fixed.
67
69
 
68
- If ``api_pkg_maker`` starts importing again (someone rewrote it, or removed
69
- it), the exclusions below are dead weight and the #14 (a)/(b) question is
70
- answered -- so this deliberately fails to force that cleanup rather than
71
- letting a stale exclusion sit there forever.
70
+ ``api-pkg-maker`` is this package's only console script, and it could not
71
+ start at all: ``from setuptools import sandbox`` raised ``ImportError`` at
72
+ import time, before ``main()`` was ever entered. Both halves are asserted --
73
+ the module imports, and the entry point the console script calls is there.
72
74
  """
73
- for name in KNOWN_UNIMPORTABLE:
74
- with pytest.raises(ImportError):
75
- importlib.import_module(name)
75
+ module = importlib.import_module("http2py.api_pkg_maker")
76
+ assert callable(module.main)
77
+ assert callable(module.mk_api_pkg)
78
+
79
+
80
+ def test_no_module_is_excluded_for_being_unimportable():
81
+ """Guard the invariant, not the past exception.
82
+
83
+ ``KNOWN_UNIMPORTABLE`` is empty. If a future change adds a module that
84
+ cannot be imported, the honest fix is to fix the module -- not to grow this
85
+ set -- because an unimportable module aborts collection for everything.
86
+ """
87
+ assert KNOWN_UNIMPORTABLE == set()
76
88
 
77
89
 
78
90
  @pytest.mark.skipif(sys.version_info < (3, 11), reason="tomllib needs Python 3.11+")
@@ -137,3 +149,67 @@ def test_excluded_test_dir_conftest_imports_without_undeclared_deps():
137
149
  sys.modules.pop("_http2py_tests_conftest", None)
138
150
 
139
151
  assert hasattr(module, "ws_app")
152
+
153
+
154
+ def test_mk_api_pkg_builds_a_source_distribution(tmp_path, monkeypatch):
155
+ """End-to-end proof that the ``setuptools.sandbox`` fix actually works.
156
+
157
+ Builds a real sdist from a spec dict -- no network, no live service -- and
158
+ checks the archive is where ``mk_api_pkg`` says it is. Also asserts the
159
+ working directory survives: the old implementation did a bare
160
+ ``os.chdir(tempdir)`` and never came back, which quietly broke any caller
161
+ that used relative paths afterwards.
162
+ """
163
+ import tarfile
164
+
165
+ pytest.importorskip(
166
+ "setuptools",
167
+ reason="mk_api_pkg shells out to `setup.py sdist`; setuptools is in the "
168
+ "dev extra, so this runs in CI but skips in a bare environment",
169
+ )
170
+
171
+ from http2py import api_pkg_maker
172
+
173
+ monkeypatch.setattr(api_pkg_maker, "OUTPUT_DIR", str(tmp_path))
174
+ cwd_before = Path.cwd()
175
+
176
+ spec = {
177
+ "openapi": "3.0.2",
178
+ "info": {"title": "d", "version": "0.1"},
179
+ "servers": [{"url": "http://localhost:3030"}],
180
+ "paths": {
181
+ "/foo": {
182
+ "post": {
183
+ "x-method_name": "foo",
184
+ "description": "foo.",
185
+ "requestBody": {
186
+ "required": True,
187
+ "content": {
188
+ "application/json": {
189
+ "schema": {
190
+ "type": "object",
191
+ "properties": {"a": {"type": "integer"}},
192
+ }
193
+ }
194
+ },
195
+ },
196
+ "responses": {
197
+ "200": {
198
+ "description": "",
199
+ "content": {"application/json": {"schema": {}}},
200
+ }
201
+ },
202
+ }
203
+ }
204
+ },
205
+ }
206
+
207
+ built = api_pkg_maker.mk_api_pkg(spec, pkg_name="probepkg", pkg_version="1.2.3")
208
+
209
+ assert Path(built).is_file()
210
+ assert Path(built).name == "probepkg-1.2.3.tar.gz"
211
+ assert Path(built).parent == tmp_path
212
+ assert Path.cwd() == cwd_before, "mk_api_pkg left the process in another directory"
213
+ with tarfile.open(built) as archive:
214
+ names = {n.split("/", 1)[-1] for n in archive.getnames()}
215
+ assert "probepkg/funcs.py" in names
@@ -0,0 +1,171 @@
1
+ """Pin ``mk_cli``'s generated command line against what argh produced.
2
+
3
+ ``http2py.cli_maker`` builds a CLI out of an OpenAPI spec. The golden in
4
+ ``tests/cli_goldens/mk_cli.json`` records the ``usage:`` line argh produced for
5
+ every command of ``tests/spec_fixture.SPEC``, recorded through
6
+ ``ArghParser().add_commands(...)`` -- exactly the call ``mk_cli`` used to make --
7
+ before the migration to ``cw``.
8
+
9
+ Only usage lines are asserted, not full ``--help`` bodies: CPython rewrites the
10
+ option column between versions and CI spans 3.10 and 3.12. The full-body diff
11
+ (zero differences across all four surfaces) was done at migration time and is
12
+ recorded in the pull request.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import dataclasses
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ import pytest
23
+
24
+ import cw
25
+
26
+ sys.path.insert(0, str(Path(__file__).parent))
27
+
28
+ from spec_fixture import SPEC # noqa: E402
29
+
30
+ GOLDEN = json.loads(
31
+ (Path(__file__).parent / "cli_goldens" / "mk_cli.json").read_text(encoding="utf-8")
32
+ )
33
+ EXPECTED = GOLDEN["usages"]
34
+
35
+
36
+ def _usages(parser):
37
+ """``{command_name: normalised usage line}``, plus ``__top__`` for the parser."""
38
+ out = {"__top__": " ".join(parser.format_usage().split())}
39
+ for action in parser._actions:
40
+ if getattr(action, "choices", None):
41
+ for name, sub in action.choices.items():
42
+ out[name] = " ".join(sub.format_usage().split())
43
+ return out
44
+
45
+
46
+ def _parser(**kwargs):
47
+ from http2py.cli_maker import mk_cli
48
+
49
+ return mk_cli(openapi_spec=SPEC, **kwargs)
50
+
51
+
52
+ def test_mk_cli_returns_a_parser_with_one_command_per_route():
53
+ parser = _parser()
54
+ assert set(_usages(parser)) == {"__top__", "get-thing", "make", "plain"}
55
+
56
+
57
+ @pytest.mark.parametrize("command", sorted(EXPECTED))
58
+ def test_usage_matches_the_argh_recording(command):
59
+ """Every command's grammar is byte-identical to what argh generated.
60
+
61
+ The recorded golden used ``prog="PROG"``; a parser built during a test run
62
+ derives its prog from ``sys.argv[0]``. Only what follows the prog is
63
+ compared, so both sides drop ``usage:`` plus the prog tokens.
64
+ """
65
+ actual = _usages(_parser())
66
+ offset = 1 if command == "__top__" else 2 # subparser progs are two tokens
67
+ assert actual[command].split()[1 + offset :] == EXPECTED[command].split()[
68
+ 1 + offset :
69
+ ], f"{command}: {actual[command]!r} != {EXPECTED[command]!r}"
70
+
71
+
72
+ def test_required_api_arguments_are_options_not_positionals():
73
+ """The load-bearing consequence of ``CLI_CONVENTION``.
74
+
75
+ Every function ``register_cli_method`` builds is all-keyword-only, so a
76
+ required argument like ``uid`` must be spelled ``-u UID`` / ``--uid UID``.
77
+ Under cw's ARGH default it would become a bare positional and every caller's
78
+ command line would silently change.
79
+ """
80
+ usage = _usages(_parser())["get-thing"]
81
+ assert "-u UID" in usage and "-p PID" in usage
82
+ assert " uid" not in usage and " pid" not in usage
83
+
84
+
85
+ def test_the_convention_is_load_bearing_and_this_test_can_fail():
86
+ """Prove the check above would catch losing ``naming=BY_NAME_IF_KWONLY``.
87
+
88
+ If this stops holding, cw's default naming has become equivalent for this
89
+ signature shape and ``CLI_CONVENTION``'s comment is stale -- not the other
90
+ way round.
91
+ """
92
+ from http2py.cli_maker import CLI_CONVENTION, register_cli_method
93
+ from http2py.client import HttpClient
94
+
95
+ client = HttpClient(SPEC)
96
+ methods = [
97
+ register_cli_method(SPEC, m, ["api_key"])
98
+ for _, m in client.__dict__.items()
99
+ if getattr(m, "method_spec", None)
100
+ ]
101
+ default = cw.mk_parser(
102
+ methods,
103
+ convention=dataclasses.replace(CLI_CONVENTION, naming=cw.ARGH.naming),
104
+ prog="PROG",
105
+ )
106
+ usage = _usages(default)["get-thing"]
107
+ assert "-u UID" not in usage, (
108
+ "cw's default naming was expected to turn the required keyword-only "
109
+ f"arguments into positionals, but produced {usage!r}"
110
+ )
111
+
112
+
113
+ def test_every_parameter_register_cli_method_builds_is_keyword_only():
114
+ """The premise ``CLI_CONVENTION`` rests on, asserted rather than assumed.
115
+
116
+ ``BY_NAME_IF_KWONLY`` reproduces argh's legacy grammar *because* these
117
+ signatures are all keyword-only. If a future change introduced an ordinary
118
+ positional parameter, the two policies would diverge for it and the golden
119
+ above is what would catch it -- but this says so directly.
120
+ """
121
+ import inspect
122
+
123
+ from http2py.cli_maker import register_cli_method
124
+ from http2py.client import HttpClient
125
+
126
+ client = HttpClient(SPEC)
127
+ for _, method in client.__dict__.items():
128
+ if not getattr(method, "method_spec", None):
129
+ continue
130
+ func = register_cli_method(SPEC, method, ["api_key"])
131
+ for param in inspect.signature(func).parameters.values():
132
+ assert param.kind in (
133
+ param.KEYWORD_ONLY,
134
+ param.VAR_POSITIONAL,
135
+ param.VAR_KEYWORD,
136
+ ), f"{func.__name__}.{param.name} is {param.kind}"
137
+
138
+
139
+ def test_dispatch_cli_raises_SystemExit_on_a_command_line_error():
140
+ """``cw.run`` RETURNS the exit code; ``dispatch_cli`` must still raise it.
141
+
142
+ argh's ``parser.dispatch()`` exited the process on a bad command line.
143
+ Without the re-raise, a broken invocation would look like success.
144
+ """
145
+ from http2py.cli_maker import dispatch_cli
146
+
147
+ argv = sys.argv
148
+ sys.argv = ["PROG", "no-such-command"]
149
+ try:
150
+ with pytest.raises(SystemExit) as excinfo:
151
+ dispatch_cli(openapi_spec=SPEC)
152
+ assert excinfo.value.code == 2
153
+ finally:
154
+ sys.argv = argv
155
+
156
+
157
+ def test_the_package_no_longer_imports_argh():
158
+ """http2py no longer declares argh; nothing may import it."""
159
+ import subprocess
160
+
161
+ proc = subprocess.run(
162
+ [
163
+ sys.executable,
164
+ "-c",
165
+ "import sys, http2py, http2py.cli_maker, http2py.api_pkg_maker; "
166
+ "sys.exit(1 if 'argh' in sys.modules else 0)",
167
+ ],
168
+ capture_output=True,
169
+ text=True,
170
+ )
171
+ assert proc.returncode == 0, f"argh was imported: {proc.stderr}"
@@ -1,21 +0,0 @@
1
- """Pytest configuration for the http2py repository.
2
-
3
- ``http2py.api_pkg_maker`` imports ``setuptools.sandbox``, which modern
4
- setuptools no longer ships, so merely *importing* the module raises
5
- ``ImportError``. Under ``--doctest-modules`` that is not a single failing test:
6
- it aborts collection for the whole session. The module is deliberately left in
7
- place (whether to rewrite it or remove it is still open on issue #14), so it is
8
- excluded from collection instead.
9
-
10
- CI excludes the same two paths via ``[tool.wads.ci.testing].exclude_paths`` in
11
- pyproject.toml, which the wads ``run-tests-uv`` action turns into ``--ignore``
12
- flags. Repeating them here is what makes a bare ``pytest`` (no flags, e.g. a
13
- local run or an editor's test runner) behave the same way as CI.
14
- ``tests/test_ci_collection_contract.py`` asserts the two lists stay in
15
- agreement, so they cannot drift apart silently.
16
- """
17
-
18
- collect_ignore = [
19
- "http2py/api_pkg_maker.py",
20
- "http2py/tests",
21
- ]
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