From 0c4892649b1690ba9caea79800e53dde5d8c0a36 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:24:30 +0000 Subject: [PATCH 1/3] fix: treat i2's NotSet signature default as "required / no default" `mk_input_schema_from_func` and `ParamsSpecifier.from_func` read defaults through the new `schema_tools.param_default`, which maps i2's `NotSet` sentinel to `Parameter.empty`. So a signature with NotSet defaults (as a re-landed i2mint/i2#88 FuncFactory would show) keeps its params required and yields a JSON-serializable OpenAPI spec, identical to the plain one. No-op for current signatures. Uses `i2.is_not_set` when available, else falls back on `i2.deco.NotSet`, so the i2 requirement is unchanged. Refs i2mint/i2#48 Co-Authored-By: Claude Opus 5 --- py2http/decorators.py | 22 +++++------ py2http/schema_tools.py | 42 +++++++++++++++++++- py2http/tests/test_not_set_defaults.py | 54 ++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 py2http/tests/test_not_set_defaults.py diff --git a/py2http/decorators.py b/py2http/decorators.py index bd01266..55a162b 100644 --- a/py2http/decorators.py +++ b/py2http/decorators.py @@ -30,7 +30,11 @@ ) from i2.errors import ModuleNotFoundIgnore -from py2http.schema_tools import mk_input_schema_from_func, validate_input +from py2http.schema_tools import ( + mk_input_schema_from_func, + param_default, + validate_input, +) from py2http.config import AIOHTTP, BOTTLE from py2http.constants import ( JSON_CONTENT_TYPE, @@ -388,16 +392,12 @@ def from_func(cls, func, _dflt_default=None): _annotations = { x.name: x.annotation for x in params if x.annotation is not Parameter.empty } - _name_and_dflts = dict() - for x in params: - dflt = x.default - if dflt is Parameter.empty: - dflt = _dflt_default - _name_and_dflts.update({x.name: dflt}) - _name_and_dflts = { - x.name: x.default if x.default is not Parameter.empty else _dflt_default - for x in params - } + + def dflt_of(param): + dflt = param_default(param) # i2's NotSet sentinel counts as "no default" + return _dflt_default if dflt is Parameter.empty else dflt + + _name_and_dflts = {x.name: dflt_of(x) for x in params} return cls( _annotations=_annotations, _dflt_default=_dflt_default, **_name_and_dflts, ) diff --git a/py2http/schema_tools.py b/py2http/schema_tools.py index 9651767..a46208f 100644 --- a/py2http/schema_tools.py +++ b/py2http/schema_tools.py @@ -9,6 +9,32 @@ from typing import Any, _TypedDictMeta, T_co, Union, _GenericAlias from i2.errors import InputError +try: + from i2 import is_not_set +except ImportError: # older i2: same sentinel, not exported from the root yet + from i2.deco import NotSet as _NotSet + + def is_not_set(x) -> bool: + """Return True iff ``x`` is ``i2``'s ``NotSet`` sentinel.""" + return x is _NotSet + + +def param_default(param: Parameter): + """Return ``param.default``, or ``Parameter.empty`` if it is ``i2``'s ``NotSet``. + + ``NotSet`` in a signature means "no value given", not a real default, so schema + builders treat that param as required, with no default (it is not JSON + serializable either). + + >>> from i2.deco import NotSet + >>> param_default(Parameter('x', Parameter.KEYWORD_ONLY, default=3)) + 3 + >>> param_default(Parameter('x', Parameter.KEYWORD_ONLY, default=NotSet)) + + """ + default = param.default + return Parameter.empty if is_not_set(default) else default + COMPLEX_TYPE_MAPPING = {} JSON_TYPES = [list, str, int, float, dict, bool] @@ -111,6 +137,18 @@ def mk_input_schema_from_func(func, exclude_keys=None, include_func_params=False ... 'z': {'type': int, 'default': 1}}, ... 'required': ['x']} >>> assert got == expected, f"\\n expected {expected}\\n got {got}" + >>> + >>> # i2's ``NotSet`` sentinel as a default (e.g. in an ``i2.FuncFactory`` + >>> # signature) means "no default": the param stays required. + >>> from i2.deco import NotSet + >>> def mult_(x: float = NotSet, y=NotSet, z: int = 1): + ... return (x * y) ** z + >>> mk_input_schema_from_func(mult_) == { + ... 'type': dict, + ... 'properties': { + ... 'x': {'type': float}, 'y': {'type': Any}, 'z': {'type': int, 'default': 1}}, + ... 'required': ['x', 'y']} + True """ if not exclude_keys: exclude_keys = {} @@ -124,8 +162,8 @@ def mk_input_schema_from_func(func, exclude_keys=None, include_func_params=False default_type = Any p = {} - if param.default != Parameter.empty: - default = param.default + default = param_default(param) # i2's NotSet sentinel counts as "no default" + if default is not Parameter.empty: if type(default) in JSON_TYPES: default_type = type(default) p['default'] = default diff --git a/py2http/tests/test_not_set_defaults.py b/py2http/tests/test_not_set_defaults.py new file mode 100644 index 0000000..5bd3343 --- /dev/null +++ b/py2http/tests/test_not_set_defaults.py @@ -0,0 +1,54 @@ +"""``i2``'s ``NotSet`` sentinel in a signature means "required / no default". + +See i2mint/i2#48: once ``i2.FuncFactory`` shows ``NotSet`` defaults, the OpenAPI spec +py2http builds must stay JSON-serializable and keep those params required. +These tests use ``i2.deco.NotSet`` directly, so they pass with any i2 version. +""" + +import json + +from i2 import Sig +from i2.deco import NotSet + +from py2http.decorators import ParamsSpecifier +from py2http.schema_tools import mk_input_schema_from_func +from py2http.service import mk_routes_and_openapi_specs + + +def foo(a: int, b: str, c=None, *, d: float = 1.5): + return a, b, c, d + + +# ``foo`` with ``NotSet`` defaults, as a re-landed i2#88 ``FuncFactory`` would show. +# Defined separately (rather than with ``Sig(foo).ch_defaults(...)(foo)``), since +# ``Sig.__call__`` sets ``__signature__`` on ``foo`` itself, in place. No docstring, +# so that its OpenAPI spec can be compared with foo's. +def foo_with_not_set(a: int = NotSet, b: str = NotSet, c=None, *, d: float = 1.5): + return a, b, c, d + + +foo_with_not_set.__name__ = foo.__name__ # same route and spec as foo + + +def test_fixture_really_has_not_set_defaults(): + assert Sig(foo_with_not_set).parameters['a'].default is NotSet + + +def test_input_schema_is_the_same_as_without_not_set(): + assert mk_input_schema_from_func(foo_with_not_set) == mk_input_schema_from_func( + foo + ) + assert mk_input_schema_from_func(foo_with_not_set)['required'] == ['a', 'b'] + + +def test_openapi_spec_is_json_serializable_and_marks_required(): + _, spec = mk_routes_and_openapi_specs([foo_with_not_set]) + serialized = json.dumps(spec) # used to raise: Sentinel is not JSON serializable + assert 'NotSet' not in serialized + _, plain_spec = mk_routes_and_openapi_specs([foo]) + assert spec == plain_spec + + +def test_params_specifier_does_not_take_not_set_as_default(): + specifier = ParamsSpecifier.from_func(foo_with_not_set, _dflt_default='dflt') + assert specifier._name_and_dflts == {'a': 'dflt', 'b': 'dflt', 'c': None, 'd': 1.5} From 22bedec606c43856e9f7d6b4f53626988d14f8d3 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:26:53 +0000 Subject: [PATCH 2/3] test: pin the OpenAPI contract for a real FuncFactory showing NotSet (review follow-up) Co-Authored-By: Claude Opus 5 --- py2http/schema_tools.py | 2 ++ py2http/tests/test_not_set_defaults.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/py2http/schema_tools.py b/py2http/schema_tools.py index a46208f..ee60e5f 100644 --- a/py2http/schema_tools.py +++ b/py2http/schema_tools.py @@ -198,6 +198,8 @@ def mk_input_schema_from_func(func, exclude_keys=None, include_func_params=False p['type'] = arg_type if include_func_params: + # The raw Parameter (which may still carry i2's NotSet default); it is + # for python-side consumers, not JSON. p['x-py-param'] = param # map key to this p info input_properties[key] = p diff --git a/py2http/tests/test_not_set_defaults.py b/py2http/tests/test_not_set_defaults.py index 5bd3343..dcc9ab3 100644 --- a/py2http/tests/test_not_set_defaults.py +++ b/py2http/tests/test_not_set_defaults.py @@ -52,3 +52,24 @@ def test_openapi_spec_is_json_serializable_and_marks_required(): def test_params_specifier_does_not_take_not_set_as_default(): specifier = ParamsSpecifier.from_func(foo_with_not_set, _dflt_default='dflt') assert specifier._name_and_dflts == {'a': 'dflt', 'b': 'dflt', 'c': None, 'd': 1.5} + + +def test_openapi_spec_of_a_func_factory_showing_not_set_defaults(): + """What a re-landed i2#88 does: a ``FuncFactory`` whose signature shows ``NotSet``.""" + from i2 import FuncFactory + + def mk_factory(): + factory = FuncFactory(foo) + factory.__name__ = 'foo_factory' # FuncFactory instances have no __name__ + return factory + + plain, with_not_set = mk_factory(), mk_factory() + sig = Sig(with_not_set) + with_not_set.__signature__ = sig.ch_defaults( + **{name: NotSet for name in sig.required_names} + ) + assert Sig(with_not_set).parameters['a'].default is NotSet + + _, spec = mk_routes_and_openapi_specs([with_not_set]) + assert 'NotSet' not in json.dumps(spec) + assert spec == mk_routes_and_openapi_specs([plain])[1] From 41c8bddec1aa4304c663faf53e967acb7bda6cf3 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:28:31 +0000 Subject: [PATCH 3/3] fix: make hosted CI green (stale http2py call, bottle 0.13 mount prefix) - tests: http2py's mk_request_func_from_openapi_spec takes method as keyword-only since 2025-08; the positional call broke collection of simple_run_process_test.py (master CI red since then). - mk_app: multi-API routes like 'foo_api' (as in its own doctest) are now mounted at '/foo_api'; bottle >= 0.13 and aiohttp reject prefixes without a leading slash, so the documented usage raised ValueError. Co-Authored-By: Claude Opus 5 --- py2http/service.py | 2 ++ py2http/tests/test_p2h2p.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/py2http/service.py b/py2http/service.py index 0c9b71b..58938db 100644 --- a/py2http/service.py +++ b/py2http/service.py @@ -486,6 +486,8 @@ def get_web_framework_objects(): parent_app, add_subapp_meth = get_web_framework_objects() for route, route_spec in app_spec.items(): + # bottle (>=0.13) and aiohttp both require mount prefixes to start with '/' + route = route if route.startswith('/') else '/' + route if isinstance(route_spec, dict): handlers = route_spec['handlers'] subapp_configs = route_spec['config'] diff --git a/py2http/tests/test_p2h2p.py b/py2http/tests/test_p2h2p.py index bf6468f..b39e46a 100644 --- a/py2http/tests/test_p2h2p.py +++ b/py2http/tests/test_p2h2p.py @@ -30,7 +30,7 @@ def client_funcs_from_openapi(openapi_spec, **h2p_configs): for (path, method), spec in e.paths_and_methods_items(): yield mk_request_func_from_openapi_spec( - path, openapi_spec, method, **h2p_configs + path, openapi_spec, method=method, **h2p_configs )