Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
"""Pytest configuration for the http2py repository.

``http2py.api_pkg_maker`` imports ``setuptools.sandbox``, which modern
setuptools no longer ships, so merely *importing* the module raises
``ImportError``. Under ``--doctest-modules`` that is not a single failing test:
it aborts collection for the whole session. The module is deliberately left in
place (whether to rewrite it or remove it is still open on issue #14), so it is
excluded from collection instead.
``http2py/tests`` stands up a real ``py2http`` web service, so it depends on a
test-only package that is not one of http2py's declared dependencies. Under
``--doctest-modules`` an unimportable module is not a single failing test: it
aborts collection for the whole session. So that directory is excluded here.

CI excludes the same two paths via ``[tool.wads.ci.testing].exclude_paths`` in
CI excludes the same path via ``[tool.wads.ci.testing].exclude_paths`` in
pyproject.toml, which the wads ``run-tests-uv`` action turns into ``--ignore``
flags. Repeating them here is what makes a bare ``pytest`` (no flags, e.g. a
local run or an editor's test runner) behave the same way as CI.
flags. Repeating it here is what makes a bare ``pytest`` (no flags, e.g. a local
run or an editor's test runner) behave the same way as CI.
``tests/test_ci_collection_contract.py`` asserts the two lists stay in
agreement, so they cannot drift apart silently.
"""

collect_ignore = [
"http2py/api_pkg_maker.py",
"http2py/tests",
]
72 changes: 61 additions & 11 deletions http2py/api_pkg_maker.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,32 @@
Given a spec (or the URL of one), :func:`mk_api_pkg` writes a small source
distribution whose functions are bound to the service's routes.

Note: this module imports ``setuptools.sandbox``, which modern setuptools no
longer provides, so importing it raises ``ImportError``. It is kept in place
pending a decision (rewrite or remove) and is excluded from test collection;
see the repo-root ``conftest.py``.
The sdist is built by running ``setup.py sdist`` in a subprocess. This module
used to call ``setuptools.sandbox.run_setup``, which modern setuptools no longer
ships -- so merely *importing* it raised ``ImportError`` and the
``api-pkg-maker`` console script could not start at all (i2mint/http2py#14).
The subprocess is also better behaved: ``sandbox.run_setup`` needed the process
to ``os.chdir`` into the build directory and never came back.

``setuptools`` is still needed to *run* :func:`mk_api_pkg`, but it is
deliberately NOT a declared dependency of http2py: it is needed by this one
function, not by ``import http2py``, and a virtualenv created by ``uv`` does not
ship it. :func:`_check_build_backend` turns its absence into an actionable
message instead of a subprocess exit code.
"""

import argh
import os
import shutil
import subprocess
import sys
import tempfile
from http2py.client import HttpClient
from datetime import datetime, timezone
from setuptools import sandbox

OUTPUT_DIR = os.path.join(os.environ["HOME"], "http2py", "api_pkgs")
#: Where built packages are written. ``expanduser`` rather than
#: ``os.environ["HOME"]``: the latter is a module-import-time ``KeyError`` on
#: Windows, which would take the whole package down with it.
OUTPUT_DIR = os.path.join(os.path.expanduser("~"), "http2py", "api_pkgs")

INIT_FILE_TPL = """from .funcs import {funcs}
"""
Expand Down Expand Up @@ -111,6 +122,8 @@ def create_file(filepath, content):
with open(filepath, "w") as file:
print(content, file=file)

_check_build_backend()

tempdir = tempfile.mkdtemp()
try:
api = HttpClient(openapi_spec=openapi_spec, url=openapi_url)
Expand Down Expand Up @@ -148,8 +161,20 @@ def create_file(filepath, content):
filepath=os.path.join(tempdir, f"setup.cfg"), content=setup_cfg_content
)
create_file(filepath=os.path.join(tempdir, f"setup.py"), content=SETUP_PY)
os.chdir(tempdir)
sandbox.run_setup("setup.py", ["sdist"])
build = subprocess.run(
[sys.executable, "setup.py", "sdist"],
cwd=tempdir,
capture_output=True,
text=True,
)
if build.returncode != 0:
# check=True would raise a CalledProcessError carrying only the exit
# status, discarding the captured output that says what went wrong.
raise RuntimeError(
f"`setup.py sdist` failed (exit {build.returncode}) while building "
f"{pkg_name!r}.\n--- stdout ---\n{build.stdout}"
f"\n--- stderr ---\n{build.stderr}"
)
pkg_filename = f"{pkg_name}-{pkg_version}.tar.gz"
if not os.path.exists(OUTPUT_DIR):
os.makedirs(OUTPUT_DIR)
Expand All @@ -161,9 +186,34 @@ def create_file(filepath, content):
shutil.rmtree(tempdir)


def _check_build_backend():
"""Fail early and legibly when the interpreter cannot build an sdist.

``setup.py sdist`` needs setuptools in the *subprocess's* interpreter, which
is this one. Environments created by ``uv`` (and ``python -m venv
--without-pip``) do not have it. Without this check the user sees a bare
``CalledProcessError`` with an exit status and no cause.
"""
import importlib.util

if importlib.util.find_spec("setuptools") is None:
raise RuntimeError(
"mk_api_pkg builds a source distribution with `setup.py sdist`, "
f"which needs setuptools in {sys.executable}. Install it with: "
f"{sys.executable} -m pip install setuptools"
)


def main():
argh.dispatch_command(mk_api_pkg)
"""Entry point for the ``api-pkg-maker`` console script; returns the exit code.

``cw.dispatch`` returns the code rather than exiting, and
``[project.scripts]`` wraps this in ``sys.exit(main())``.
"""
import cw

return cw.dispatch(mk_api_pkg)


if __name__ == "__main__":
main()
raise SystemExit(main())
44 changes: 36 additions & 8 deletions http2py/cli_maker.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
"""Turn an http-bound python object into a command line interface.

Signatures are first made argparse-friendly (:func:`mk_argparse_friendly`),
then dispatched with ``argh`` by :func:`mk_cli` / :func:`dispatch_cli`.
then turned into a parser by :func:`mk_cli` and run by :func:`dispatch_cli`.

The parser is built by ``cw``. Its ``BY_NAME_IF_KWONLY`` naming is what
reproduces the grammar this module has always produced -- see
:data:`CLI_CONVENTION`.
"""

import argh
import dataclasses
from functools import wraps
from glom import glom
from inspect import signature
Expand All @@ -15,11 +19,30 @@
from collections.abc import Callable, Iterable
import yaml

import cw

from i2.io_trans import JSONAnnotAndDfltIoTrans
from i2.signatures import set_signature_of_func, Sig, KO
from http2py import HttpClient
from http2py.authentication import mk_auth, DFLT_CONFIG_FILENAME

#: How the command line is derived from a signature.
#:
#: Every function :func:`register_cli_method` builds has KEYWORD-ONLY parameters
#: only (plus ``*args``/``**kwargs`` on a no-argument route), because
#: :func:`Sig.merge_with_sig` is called with ``kind=KO``. For that shape,
#: ``BY_NAME_IF_KWONLY`` is what reproduces the grammar this module produced
#: under argh: every parameter is an option, required exactly when it has no
#: default -- ``get-thing -u UID -p PID [-l LIMIT]``.
#:
#: cw's ``ARGH`` default (``BY_NAME_IF_HAS_DEFAULT``) would NOT: it turns a
#: keyword-only parameter with no default into a *positional*, so every required
#: API argument would silently change spelling. That is not a cw bug -- it
#: faithfully reproduces argh's *explicit* ``BY_NAME_IF_HAS_DEFAULT`` policy.
#: This module never selected a policy, and argh's no-policy legacy mode is the
#: one that matches ``BY_NAME_IF_KWONLY`` here.
CLI_CONVENTION = dataclasses.replace(cw.ARGH, naming=cw.BY_NAME_IF_KWONLY)


def mk_sig_argparse_friendly(sig):
"""Modifies a signature to change all leading underscores in param names
Expand Down Expand Up @@ -109,15 +132,20 @@ def mk_cli(
for methodname, method in client_details.__dict__.items()
if getattr(method, "method_spec", None)
]
parser = argh.ArghParser()
parser.add_commands(cli_methods)
return parser
return cw.mk_parser(cli_methods, convention=CLI_CONVENTION)


def dispatch_cli(*args, **kwargs):
"""Makes a CLI parser with mk_cli and then dispatches it (see documentation of mk_cli)"""
parser = mk_cli(*args, **kwargs)
parser.dispatch()
"""Makes a CLI parser with mk_cli and then runs it (see documentation of mk_cli)

Returns ``None`` when the command ran, and raises ``SystemExit(2)`` on a
command-line error -- the same two outcomes the previous argh-based
implementation produced. ``cw.run`` *returns* the exit code rather than
exiting, so the non-zero case is re-raised here.
"""
code = cw.run(mk_cli(*args, **kwargs))
if code:
raise SystemExit(code)


set_signature_of_func(dispatch_cli, signature(mk_cli))
Expand Down
13 changes: 9 additions & 4 deletions http2py/example_cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""A tiny worked example of the CLI-making tools, used by the docs and by hand."""

import argh
import cw
from collections.abc import Iterable

from http2py.cli_maker import mk_argparse_friendly
Expand All @@ -13,6 +13,11 @@ def myfun(a: int, b: Iterable[int]):


if __name__ == "__main__":
parser = argh.ArghParser()
parser.add_commands([mk_argparse_friendly(AnnotAndDfltIoTrans()(myfun))])
parser.dispatch()
from http2py.cli_maker import CLI_CONVENTION

raise SystemExit(
cw.dispatch(
[mk_argparse_friendly(AnnotAndDfltIoTrans()(myfun))],
convention=CLI_CONVENTION,
)
)
24 changes: 15 additions & 9 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ dependencies = [
"i2",
"ju",
"requests",
"argh",
"cw>=0.1.1,<0.2",
"PyYAML",
"importlib_resources",
# TEMPORARY, not imported by http2py: ju/oas.py has an unconditional (and
Expand All @@ -57,6 +57,11 @@ dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
"ruff>=0.1.0",
# Not a runtime dependency of the package: `mk_api_pkg` shells out to
# `setup.py sdist`, so setuptools is needed to exercise it. A uv-created
# venv does not ship setuptools, so without this the end-to-end
# api_pkg_maker test would silently skip in CI.
"setuptools",
]

[tool.hatch.build.targets.wheel]
Expand Down Expand Up @@ -160,15 +165,16 @@ coverage_threshold = 0
coverage_report_format = [
"term",
]
# `http2py.api_pkg_maker` imports `setuptools.sandbox`, which modern setuptools
# no longer ships, so the module raises ImportError on import and would abort
# the whole collection. Whether to rewrite it or drop it is still open on
# issue #14; excluding it from collection is the reversible middle course.
# The same two paths are repeated in the repo-root conftest.py so that a bare
# `pytest` behaves like CI -- tests/test_ci_collection_contract.py keeps the
# two lists in agreement.
# `http2py/tests` stands up a real py2http web service, so it needs a test-only
# dependency that CI does not install. Excluding it from collection is what keeps
# `pytest --doctest-modules` from aborting the session on the import.
# (`http2py/api_pkg_maker.py` used to be excluded here too, because
# `setuptools.sandbox` no longer exists and the module was unimportable. It is
# fixed and collected again -- see issue #14.)
# This path is repeated in the repo-root conftest.py so that a bare `pytest`
# behaves like CI -- tests/test_ci_collection_contract.py keeps the two lists
# in agreement.
exclude_paths = [
"http2py/api_pkg_maker.py",
"http2py/tests",
]
test_on_windows = false
Expand Down
10 changes: 10 additions & 0 deletions tests/cli_goldens/mk_cli.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"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",
"note": "Normalised usage lines only: CPython rewrites --help bodies between versions and CI spans 3.10 and 3.12.",
"usages": {
"__top__": "usage: PROG [-h] {get-thing,make,plain} ...",
"get-thing": "usage: PROG get-thing [-h] -u UID -p PID [-v VERBOSE] [-l LIMIT] [-a API_KEY] [-c CONFIG]",
"make": "usage: PROG make [-h] -n NAME [--count COUNT] [-f FLAG] [-a API_KEY] [--config CONFIG]",
"plain": "usage: PROG plain [-h] [-a API_KEY] [-c CONFIG] [args ...]"
}
}
89 changes: 89 additions & 0 deletions tests/spec_fixture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""One OpenAPI spec exercising every argument shape ``mk_cli`` has to handle.

Path parameters (required, and typed), query parameters (optional, with and
without a schema default), a JSON request body with a ``required`` list, an
``apiKey`` security scheme (which adds an ``api_key`` CLI argument), and a route
with no arguments at all (which yields a ``*args``/``**kwargs`` signature).

Kept as a module rather than inline so the golden recorder and the tests are
provably looking at the same spec.
"""

CONTENT = "application/json"

SPEC = {
"openapi": "3.0.2",
"info": {"title": "example", "version": "0.1"},
"servers": [{"url": "https://example.com"}],
"security": {"apiKey": {}},
"paths": {
"/u/{uid}/p/{pid}": {
"get": {
"x-method_name": "get_thing",
"description": "Two path args and two query args.",
"parameters": [
{
"name": "uid",
"in": "path",
"required": True,
"schema": {"type": "string"},
},
{
"name": "pid",
"in": "path",
"required": True,
"schema": {"type": "integer"},
},
{
"name": "verbose",
"in": "query",
"required": False,
"schema": {"type": "boolean"},
},
{
"name": "limit",
"in": "query",
"required": False,
"schema": {"type": "integer", "default": 10},
},
],
"responses": {
"200": {"description": "", "content": {CONTENT: {"schema": {}}}}
},
}
},
"/make": {
"post": {
"x-method_name": "make",
"description": "A request body with one required property.",
"requestBody": {
"content": {
CONTENT: {
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"count": {"type": "integer"},
"flag": {"type": "boolean"},
},
"required": ["name"],
}
}
}
},
"responses": {
"200": {"description": "", "content": {CONTENT: {"schema": {}}}}
},
}
},
"/plain": {
"get": {
"x-method_name": "plain",
"description": "No arguments at all.",
"responses": {
"200": {"description": "", "content": {CONTENT: {"schema": {}}}}
},
}
},
},
}
Loading