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
22 changes: 11 additions & 11 deletions py2http/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down
44 changes: 42 additions & 2 deletions py2http/schema_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
<class 'inspect._empty'>
"""
default = param.default
return Parameter.empty if is_not_set(default) else default

COMPLEX_TYPE_MAPPING = {}
JSON_TYPES = [list, str, int, float, dict, bool]

Expand Down Expand Up @@ -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 = {}
Expand All @@ -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
Expand Down Expand Up @@ -160,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
Expand Down
2 changes: 2 additions & 0 deletions py2http/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
75 changes: 75 additions & 0 deletions py2http/tests/test_not_set_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""``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}


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]
2 changes: 1 addition & 1 deletion py2http/tests/test_p2h2p.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)


Expand Down
Loading