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
6 changes: 5 additions & 1 deletion http2py/api_pkg_maker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -103,7 +106,7 @@ def {func_name}(*args, **kwargs):
zip_safe = False
install_requires =
i2
http2py
http2py>={min_http2py}

[options.package_data]
* = {spec_filename}
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions http2py/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
29 changes: 28 additions & 1 deletion http2py/py2request.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@
from functools import wraps
from inspect import signature, Parameter
import json
import keyword
import pickle
from glom import glom
from requests import request
import string
import urllib
from urllib.parse import urlparse

# import io
import re
Expand Down Expand Up @@ -607,13 +609,38 @@ 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, 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``.
"""
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(
openapi_method_spec,
method="post",
url_template="",
content_type=JSON_CONTENT_TYPE,
input_trans=None,
output_trans=None,
pathname=None,
):
path_arg_names = []
query_arg_names = []
Expand Down Expand Up @@ -671,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=openapi_method_spec.get("x-method_name", ""),
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"))),
Expand Down
58 changes: 58 additions & 0 deletions tests/test_route_namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,61 @@ 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"]


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
Loading