From cf24a7299fd0330ed09eb148d36a40b861a44569 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:11:39 +0000 Subject: [PATCH 1/2] Name routes without x-method_name; pin http2py>=0.1.37 in generated packages (#25, #26) A route with no x-method_name is now named from its operationId, else from method + path, so such routes no longer all register as request_func. Generated setup.cfg requires http2py>=0.1.37, the first release with HttpClient.routes. Co-Authored-By: Claude Sonnet 5.5 --- http2py/api_pkg_maker.py | 6 ++++- http2py/py2request.py | 26 +++++++++++++++++++- tests/test_route_namespace.py | 45 +++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/http2py/api_pkg_maker.py b/http2py/api_pkg_maker.py index de402e3..0beec38 100644 --- a/http2py/api_pkg_maker.py +++ b/http2py/api_pkg_maker.py @@ -84,6 +84,9 @@ def {func_name}(*args, **kwargs): return api.routes[{func_name!r}](*args, **kwargs) """ +# The first http2py release with ``HttpClient.routes``, which generated packages call. +MIN_HTTP2PY_VERSION = "0.1.37" + SETUP_PY = """from setuptools import setup setup() @@ -103,7 +106,7 @@ def {func_name}(*args, **kwargs): zip_safe = False install_requires = i2 - http2py + http2py>={min_http2py} [options.package_data] * = {spec_filename} @@ -215,6 +218,7 @@ def api_pkg_files( author=_cfg_value(pkg_author or "API Package Maker"), license=_cfg_value(pkg_license or "Apache"), spec_filename=SPEC_FILENAME, + min_http2py=MIN_HTTP2PY_VERSION, ) funcs_code = FUNCS_FILE_TPL.format( spec_filename=SPEC_FILENAME, diff --git a/http2py/py2request.py b/http2py/py2request.py index 58fe432..49841d9 100644 --- a/http2py/py2request.py +++ b/http2py/py2request.py @@ -33,11 +33,14 @@ from functools import wraps from inspect import signature, Parameter import json +import keyword import pickle +import re from glom import glom from requests import request import string import urllib +from urllib.parse import urlparse # import io import re @@ -607,6 +610,27 @@ def _func(response): return _func +def _identifier(text): + """``text`` as a public Python identifier: runs of other characters (and of ``_``) become ``_``, + a leading digit gets an ``op_`` prefix and a keyword a trailing ``_``.""" + name = re.sub(r"[\W_]+", "_", text, flags=re.ASCII).strip("_") + if name[:1].isdigit(): + name = f"op_{name}" + return f"{name}_" if keyword.iskeyword(name) else name + + +def _route_name(openapi_method_spec, method, url_template): + """The name of the function for a route: its ``x-method_name``, else its + ``operationId``, else the http method and the path (``get /users/{id}`` gives + ``get_users_id``), so that routes without ``x-method_name`` do not all share a name. + """ + for key in ("x-method_name", "operationId"): + name = openapi_method_spec.get(key) + if name: + return name if key == "x-method_name" else (_identifier(name) or name) + return default_path_to_func_name(method, urlparse(url_template).path) + + def mk_method_spec_from_openapi_method_spec( openapi_method_spec, method="post", @@ -671,7 +695,7 @@ def mk_method_spec_from_openapi_method_spec( path_arg_names=path_arg_names, query_arg_names=query_arg_names, body_arg_names=body_arg_names, - method_name=openapi_method_spec.get("x-method_name", ""), + method_name=_route_name(openapi_method_spec, method, url_template), docstring=openapi_method_spec.get("description", ""), content_type=content_type, response_type=next(iter(glom(openapi_method_spec, f"responses.200.content"))), diff --git a/tests/test_route_namespace.py b/tests/test_route_namespace.py index 9dddc08..37184fb 100644 --- a/tests/test_route_namespace.py +++ b/tests/test_route_namespace.py @@ -104,3 +104,48 @@ def __init__(self): spec = SPEC["paths"]["/plain"]["get"] bare.register_method("https://example.com/plain", "get", spec) assert bare.routes["plain"] is bare.plain + + +def _spec_without_names(): + """A spec whose routes have no ``x-method_name`` (i2mint/http2py#26).""" + spec = copy.deepcopy(SPEC) + for path_item in spec["paths"].values(): + for op in path_item.values(): + op.pop("x-method_name", None) + return spec + + +def test_routes_without_x_method_name_get_distinct_names_from_method_and_path(): + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("error") + client = _client(_spec_without_names()) + assert len(client.routes) == len(SPEC["paths"]) == 3 + assert "get_plain" in client.routes + for name, route in client.routes.items(): + assert route.__name__ == name != "request_func" + + +def test_operation_id_names_a_route_without_x_method_name(): + spec = _spec_without_names() + spec["paths"]["/plain"]["get"]["operationId"] = "list-plain.things" + assert "list_plain_things" in _client(spec).routes + + +def test_x_method_name_wins_over_operation_id(): + spec = _spec_naming_plain("mine") + spec["paths"]["/plain"]["get"]["operationId"] = "theirs" + assert "mine" in _client(spec).routes + + +def test_api_pkg_for_a_spec_without_x_method_names(): + files = api_pkg_files(_spec_without_names(), pkg_name="genpkg_d") + assert "def get_plain(" in files["genpkg_d/funcs.py"] + + +def test_generated_package_pins_the_http2py_that_has_client_routes(): + from http2py.api_pkg_maker import MIN_HTTP2PY_VERSION + + files = api_pkg_files(SPEC, pkg_name="genpkg_e") + assert f"http2py>={MIN_HTTP2PY_VERSION}" in files["setup.cfg"] From fa978309070138cf5e9f3f79f3b2c7b4222ce23f Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:13:45 +0000 Subject: [PATCH 2/2] Review fixes: unusable operationId falls back to method+path; names ignore the server base path Co-Authored-By: Claude Sonnet 5.5 --- http2py/client.py | 9 +++++++-- http2py/py2request.py | 19 +++++++++++-------- tests/test_route_namespace.py | 13 +++++++++++++ 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/http2py/client.py b/http2py/client.py index 9df09ae..87402f7 100644 --- a/http2py/client.py +++ b/http2py/client.py @@ -115,7 +115,9 @@ def __init__( for pathname, path_spec in openapi_spec["paths"].items(): url_template = self.base_url + pathname for http_method, openapi_method_spec in path_spec.items(): - self.register_method(url_template, http_method, openapi_method_spec) + self.register_method( + url_template, http_method, openapi_method_spec, pathname=pathname + ) def init_security(self, openapi_spec, **auth_kwargs): security = openapi_spec["security"] @@ -143,7 +145,9 @@ def init_security(self, openapi_spec, **auth_kwargs): if jwt: self.set_header({"Authorization": f"Bearer {jwt}"}) - def register_method(self, url_template, http_method, openapi_method_spec): + def register_method( + self, url_template, http_method, openapi_method_spec, *, pathname=None + ): content_type = None if "requestBody" in openapi_method_spec: content_type = next( @@ -154,6 +158,7 @@ def register_method(self, url_template, http_method, openapi_method_spec): method=http_method, url_template=url_template, content_type=content_type, + pathname=pathname, ) func = mk_request_function( method_spec, diff --git a/http2py/py2request.py b/http2py/py2request.py index 49841d9..2a7caf1 100644 --- a/http2py/py2request.py +++ b/http2py/py2request.py @@ -35,7 +35,6 @@ import json import keyword import pickle -import re from glom import glom from requests import request import string @@ -619,16 +618,19 @@ def _identifier(text): return f"{name}_" if keyword.iskeyword(name) else name -def _route_name(openapi_method_spec, method, url_template): +def _route_name(openapi_method_spec, method, pathname, url_template): """The name of the function for a route: its ``x-method_name``, else its ``operationId``, else the http method and the path (``get /users/{id}`` gives ``get_users_id``), so that routes without ``x-method_name`` do not all share a name. + ``pathname`` is the path as the spec gives it, so that a server's base url does not + change the names; it defaults to the path of ``url_template``. """ - for key in ("x-method_name", "operationId"): - name = openapi_method_spec.get(key) - if name: - return name if key == "x-method_name" else (_identifier(name) or name) - return default_path_to_func_name(method, urlparse(url_template).path) + if openapi_method_spec.get("x-method_name"): + return openapi_method_spec["x-method_name"] + name = _identifier(openapi_method_spec.get("operationId") or "") + if pathname is None: + pathname = urlparse(url_template).path + return name or default_path_to_func_name(method, pathname) def mk_method_spec_from_openapi_method_spec( @@ -638,6 +640,7 @@ def mk_method_spec_from_openapi_method_spec( content_type=JSON_CONTENT_TYPE, input_trans=None, output_trans=None, + pathname=None, ): path_arg_names = [] query_arg_names = [] @@ -695,7 +698,7 @@ def mk_method_spec_from_openapi_method_spec( path_arg_names=path_arg_names, query_arg_names=query_arg_names, body_arg_names=body_arg_names, - method_name=_route_name(openapi_method_spec, method, url_template), + method_name=_route_name(openapi_method_spec, method, pathname, url_template), docstring=openapi_method_spec.get("description", ""), content_type=content_type, response_type=next(iter(glom(openapi_method_spec, f"responses.200.content"))), diff --git a/tests/test_route_namespace.py b/tests/test_route_namespace.py index 37184fb..325d9c6 100644 --- a/tests/test_route_namespace.py +++ b/tests/test_route_namespace.py @@ -149,3 +149,16 @@ def test_generated_package_pins_the_http2py_that_has_client_routes(): files = api_pkg_files(SPEC, pkg_name="genpkg_e") assert f"http2py>={MIN_HTTP2PY_VERSION}" in files["setup.cfg"] + + +def test_derived_names_do_not_depend_on_the_server_url(): + spec = _spec_without_names() + spec["servers"] = [{"url": "https://example.com/v1/api"}] + assert set(_client(spec).routes) == set(_client(_spec_without_names()).routes) + + +@pytest.mark.parametrize("operation_id", ["-", "___", "日本"]) +def test_unusable_operation_id_falls_back_to_method_and_path(operation_id): + spec = _spec_without_names() + spec["paths"]["/plain"]["get"]["operationId"] = operation_id + assert "get_plain" in _client(spec).routes