diff --git a/server/src/uds/REST/documentation.py b/server/src/uds/REST/documentation.py index af7d49114..7775ef994 100644 --- a/server/src/uds/REST/documentation.py +++ b/server/src/uds/REST/documentation.py @@ -162,7 +162,7 @@ class Documentation(View): help_data.append(HelpInfo(f'{path}/{func.value.method}', func.value.text, method)) case _: for method in node.methods: - help_data.append(HelpInfo(path, node.help.text, method)) + help_data.append(HelpInfo(path, node.help.description, method)) for child in node.children: _process_node(child, child.help.path) diff --git a/server/src/uds/REST/handlers.py b/server/src/uds/REST/handlers.py index 482cfaccd..c09b68842 100644 --- a/server/src/uds/REST/handlers.py +++ b/server/src/uds/REST/handlers.py @@ -29,6 +29,7 @@ """ Author: Adolfo Gómez, dkmaster at dkmon dot com """ +import abc import typing import logging import codecs @@ -53,7 +54,7 @@ if typing.TYPE_CHECKING: logger = logging.getLogger(__name__) -class Handler: +class Handler(abc.ABC): """ REST requests handler base class """ @@ -71,7 +72,7 @@ class Handler: # For implementing help # A list of pairs of (path, help) for subpaths on this handler - help_paths: typing.ClassVar[list[types.rest.HelpPath]] = [] + help_paths: typing.ClassVar[list[types.rest.HelpDoc]] = [] help_text: typing.ClassVar[str] = 'No help available' _request: 'ExtendedHttpRequestWithUser' # It's a modified HttpRequest diff --git a/server/src/uds/REST/methods/accounts.py b/server/src/uds/REST/methods/accounts.py index 88643fdbe..b8f2497c2 100644 --- a/server/src/uds/REST/methods/accounts.py +++ b/server/src/uds/REST/methods/accounts.py @@ -91,18 +91,14 @@ class Accounts(ModelHandler): def timemark(self, item: 'Model') -> typing.Any: """ API: - Description: - Generates a time mark associated with the account. - This is useful to easily identify when the account data was last updated. - (For example, one user enters an service, we get the usage data and "timemark" it, later read again - and we can identify that all data before this timemark has already been processed) + Generates a time mark associated with the account. + This is useful to easily identify when the account data was last updated. + (For example, one user enters an service, we get the usage data and "timemark" it, later read again + and we can identify that all data before this timemark has already been processed) + + Arguments: + item: Account to timemark - Parameters: - - item: Account - Description of the item parameter - - Response: - 200: All fine, no data returned """ item = ensure.is_instance(item, Account) item.time_mark = datetime.datetime.now() @@ -114,15 +110,7 @@ class Accounts(ModelHandler): Api documentation for the method. From here, will be used by the documentation generator Always starts with API: API: - Description: - Clears all usage associated with the account - - Parameters: - - item: Account - Description of the item parameter - - Response: - 200: All fine, no data returned + Clears all usage associated with the account """ item = ensure.is_instance(item, Account) self.ensure_has_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT) diff --git a/server/src/uds/REST/methods/authenticators.py b/server/src/uds/REST/methods/authenticators.py index a321c06bb..f7240ee6c 100644 --- a/server/src/uds/REST/methods/authenticators.py +++ b/server/src/uds/REST/methods/authenticators.py @@ -201,13 +201,7 @@ class Authenticators(ModelHandler): def search(self, item: 'Model') -> list[types.rest.ItemDictType]: """ API: - Description: - Search for users or groups in this authenticator - - - - Args: - item: Authenticator instance + Search for users or groups in this authenticator """ item = ensure.is_instance(item, Authenticator) self.ensure_has_access(item, types.permissions.PermissionType.READ) diff --git a/server/src/uds/REST/methods/config.py b/server/src/uds/REST/methods/config.py index ac4907121..851b14e07 100644 --- a/server/src/uds/REST/methods/config.py +++ b/server/src/uds/REST/methods/config.py @@ -45,12 +45,7 @@ logger = logging.getLogger(__name__) class Config(Handler): """ API: - Description: Get or update UDS configuration - - Actions: - - GET: Returns the configuration values - - PUT: Updates the configuration values - + Get or update UDS configuration """ min_access_role = consts.UserRole.ADMIN diff --git a/server/src/uds/REST/methods/gui_callback.py b/server/src/uds/REST/methods/gui_callback.py index 028fef2e1..eabe631e2 100644 --- a/server/src/uds/REST/methods/gui_callback.py +++ b/server/src/uds/REST/methods/gui_callback.py @@ -44,8 +44,7 @@ logger = logging.getLogger(__name__) class Callback(Handler): """ API: - Description: - Executes a callback from the GUI. Internal use, not intended to be called from outside. + Executes a callback from the GUI. Internal use, not intended to be called from outside. """ path = 'gui' diff --git a/server/src/uds/REST/methods/meta_pools.py b/server/src/uds/REST/methods/meta_pools.py index 6a87b8b8e..cd4450dfe 100644 --- a/server/src/uds/REST/methods/meta_pools.py +++ b/server/src/uds/REST/methods/meta_pools.py @@ -292,11 +292,7 @@ class MetaPools(ModelHandler): def set_fallback_access(self, item: MetaPool) -> typing.Any: """ API: - Description: - Sets the fallback access for a metapool - - Response: - 200: All fine, no data returned + Sets the fallback access for a metapool """ self.ensure_has_access(item, types.permissions.PermissionType.MANAGEMENT) diff --git a/server/src/uds/REST/methods/stats.py b/server/src/uds/REST/methods/stats.py index 091c33bfd..aa4dd4791 100644 --- a/server/src/uds/REST/methods/stats.py +++ b/server/src/uds/REST/methods/stats.py @@ -47,7 +47,7 @@ class Stats(Handler): min_access_role = consts.UserRole.ADMIN help_paths = [ - types.rest.HelpPath('', 'Returns the last day usage statistics for all authenticators'), + types.rest.HelpDoc('', 'Returns the last day usage statistics for all authenticators'), ] help_text = 'Provides access to usage statistics' diff --git a/server/src/uds/REST/methods/system.py b/server/src/uds/REST/methods/system.py index c3b4abd1a..00799b8fb 100644 --- a/server/src/uds/REST/methods/system.py +++ b/server/src/uds/REST/methods/system.py @@ -146,15 +146,15 @@ class System(Handler): min_access_role = consts.UserRole.STAFF help_paths = [ - types.rest.HelpPath('', ''), - types.rest.HelpPath('stats/assigned', ''), - types.rest.HelpPath('stats/inuse', ''), - types.rest.HelpPath('stats/cached', ''), - types.rest.HelpPath('stats/complete', ''), - types.rest.HelpPath('stats/assigned/', 'Get service pool assigned stats'), - types.rest.HelpPath('stats/inuse/', 'Get service pool in use stats'), - types.rest.HelpPath('stats/cached/', 'Get service pool cached stats'), - types.rest.HelpPath('stats/complete/', 'Get service pool complete stats'), + types.rest.HelpDoc('', ''), + types.rest.HelpDoc('stats/assigned', ''), + types.rest.HelpDoc('stats/inuse', ''), + types.rest.HelpDoc('stats/cached', ''), + types.rest.HelpDoc('stats/complete', ''), + types.rest.HelpDoc('stats/assigned/', 'Get service pool assigned stats'), + types.rest.HelpDoc('stats/inuse/', 'Get service pool in use stats'), + types.rest.HelpDoc('stats/cached/', 'Get service pool cached stats'), + types.rest.HelpDoc('stats/complete/', 'Get service pool complete stats'), ] help_text = 'Provides system information. Must be admin to access this' diff --git a/server/src/uds/REST/methods/tunnels_management.py b/server/src/uds/REST/methods/tunnels_management.py index afb7a2b5d..6b3bd203d 100644 --- a/server/src/uds/REST/methods/tunnels_management.py +++ b/server/src/uds/REST/methods/tunnels_management.py @@ -124,8 +124,7 @@ class TunnelServers(DetailHandler): def maintenance(self, parent: 'Model', id: str) -> typing.Any: """ API: - Description: - Custom method that swaps maintenance mode state for a tunnel server + Custom method that swaps maintenance mode state for a tunnel server """ parent = ensure.is_instance(parent, models.ServerGroup) diff --git a/server/src/uds/REST/methods/users_groups.py b/server/src/uds/REST/methods/users_groups.py index 983a30dfc..3f9945a5d 100644 --- a/server/src/uds/REST/methods/users_groups.py +++ b/server/src/uds/REST/methods/users_groups.py @@ -295,14 +295,7 @@ class Users(DetailHandler): def services_pools(self, parent: 'Model', item: str) -> list[dict[str, typing.Any]]: """ API: - Description: - Returns the service pools assigned to a user - - Parameters: - - uuid: User - - Response: - - 200: A list of service pools assigned to the user + Returns the service pools assigned to a user """ parent = ensure.is_instance(parent, Authenticator) uuid = process_uuid(item) diff --git a/server/src/uds/REST/processors.py b/server/src/uds/REST/processors.py index 1cb756dfd..9d2e9bace 100644 --- a/server/src/uds/REST/processors.py +++ b/server/src/uds/REST/processors.py @@ -40,7 +40,7 @@ import typing from django.http import HttpResponse from django.utils.functional import Promise as DjangoPromise -from uds.core import consts +from uds.core import consts, types from .utils import to_incremental_json @@ -112,6 +112,10 @@ class ContentProcessor: if obj is None or isinstance(obj, (bool, int, float, str)): return obj + # If we are a typed response... + if isinstance(obj, types.rest.TypedResponse): + return ContentProcessor.process_for_render(obj.as_dict()) + if isinstance(obj, DjangoPromise): return str(obj) # This is for translations diff --git a/server/src/uds/core/types/rest.py b/server/src/uds/core/types/rest.py index dcb678b72..e56e0f5b2 100644 --- a/server/src/uds/core/types/rest.py +++ b/server/src/uds/core/types/rest.py @@ -40,6 +40,51 @@ if typing.TYPE_CHECKING: from uds.REST.handlers import Handler +# TypedResponse related. +# Typed responses are used to define the type of the response that a method will return. +# This allow us to "describe" it later on the documentation, and also to check that the +# response is correct (and also to generate the response in the correct format) +class TypedResponse(abc.ABC): + def as_dict(self) -> dict[str, typing.Any]: + # If we are a dataclass + if dataclasses.is_dataclass(self): + return dataclasses.asdict(self) + # If we are a dict + if isinstance(self, dict): + return self + + raise Exception(f'Cannot convert {self} to dict') + + @classmethod + def as_help(cls: type) -> dict[str, typing.Any]: + """ + Returns a representation, as json, of the response type to be used on documentation + + For this, we build a dict of "name": "" for each field of the response and returns it + Note that we support nested dataclasses and dicts, but not lists + """ + CLASS_REPR: dict[typing.Any, str] = { + str: '', + int: '', + float: '', + bool: '', + dict: '', + list: '', + typing.Any: '', + } + + def _as_help(obj: typing.Any) -> typing.Union[str, dict[str, typing.Any]]: + if dataclasses.is_dataclass(obj): + return {field.name: _as_help(field.type) for field in dataclasses.fields(obj)} + if isinstance(obj, dict): + return {k: str(_as_help(v)) for k, v in typing.cast(dict[str, typing.Any], obj).items()} + + return CLASS_REPR.get(obj, str(obj)) + + return {field.name: _as_help(field.type) for field in dataclasses.fields(cls)} + + +# Type related definitions TypeInfoDict = dict[str, typing.Any] # Alias for type info dict @@ -121,40 +166,78 @@ API_RE = re.compile(r'(?ms)^\s*API:\s*$') @dataclasses.dataclass(eq=False) -class HelpPath: +class HelpDoc: """ Help helper class """ + @dataclasses.dataclass + class ArgumentInfo: + name: str + type: str + description: str + path: str - text: str + description: str + arguments: list[ArgumentInfo] = dataclasses.field(default_factory=list) + # Result is always a json ressponse, so we can describe it as a dict + # Note that this dict can be nested + returns: dict[str, typing.Any] = dataclasses.field(default_factory=dict) - def __init__(self, path: str, help: str): + def __init__( + self, + path: str, + help: str, + *, + arguments: typing.Optional[list[ArgumentInfo]] = None, + returns: typing.Optional[dict[str, typing.Any]] = None, + ) -> None: self.path = path - self.text = HelpPath.process_help(help) + self.description = help + self.arguments = arguments or [] + self.returns = returns or {} def __hash__(self) -> int: return hash(self.path) def __eq__(self, other: object) -> bool: - if not isinstance(other, HelpPath): + if not isinstance(other, HelpDoc): return False return self.path == other.path + def as_str(self) -> str: + return f'{self.path} - {self.description}' + @property def is_empty(self) -> bool: - return self.path == '' and self.text == '' + return self.path == '' and self.description == '' - @staticmethod - def process_help(help: str) -> str: + def _process_help(self, help: str, annotations: typing.Optional[dict[str, typing.Any]] = None) -> None: """ Processes the help string, removing leading and trailing spaces """ + self.description = '' + self.arguments = [] + self.returns = {} + match = API_RE.search(help) if match: - return help[match.end() :].strip() + self.description = help[: match.start()].strip() - return '' + if annotations: + if 'return' in annotations and issubclass(annotations['return'], TypedResponse): + self.returns = annotations['return'].as_help() + + @staticmethod + def from_typed_response(path: str, help: str, TR: type[TypedResponse]) -> 'HelpDoc': + """ + Returns a HelpDoc from a TypedResponse class + """ + return HelpDoc( + path=path, + help=help, + returns=TR.as_help(), + ) @dataclasses.dataclass(frozen=True) @@ -164,7 +247,7 @@ class HelpNode: DETAIL = 'detail' CUSTOM = 'custom' PATH = 'path' - + class Methods(enum.StrEnum): GET = 'GET' POST = 'POST' @@ -172,7 +255,7 @@ class HelpNode: DELETE = 'DELETE' PATCH = 'PATCH' - help: HelpPath + help: HelpDoc children: list['HelpNode'] # Children nodes kind: Type methods: set[Methods] = dataclasses.field(default_factory=lambda: {HelpNode.Methods.GET}) @@ -183,7 +266,7 @@ class HelpNode: def __eq__(self, other: object) -> bool: if isinstance(other, HelpNode): return self.help.path == other.help.path and self.methods == other.methods - if not isinstance(other, HelpPath): + if not isinstance(other, HelpDoc): return False return self.help.path == other.path @@ -256,10 +339,14 @@ class HandlerNode: # Method is a Me CustomModelMethod, # We access the __doc__ of the function inside the handler with method.name doc = getattr(self.handler, method.name).__doc__ or '' - path = f'{self.full_path()}/{method.name}' if not method.needs_parent else f'{self.full_path()}//{method.name}' + path = ( + f'{self.full_path()}/{method.name}' + if not method.needs_parent + else f'{self.full_path()}//{method.name}' + ) custom_help.add( HelpNode( - HelpPath(path=path, help=doc), + HelpDoc(path=path, help=doc), [], HelpNode.Type.CUSTOM, ) @@ -270,7 +357,7 @@ class HandlerNode: for method_name, method_class in self.handler.detail.items(): custom_help.add( HelpNode( - HelpPath(path=self.full_path() + '/' + method_name, help=''), + HelpDoc(path=self.full_path() + '/' + method_name, help=''), [], HelpNode.Type.DETAIL, ) @@ -282,7 +369,7 @@ class HandlerNode: doc = getattr(method_class, detail_method).__doc__ or '' custom_help.add( HelpNode( - HelpPath( + HelpDoc( path=self.full_path() + '//' + method_name @@ -297,9 +384,9 @@ class HandlerNode: custom_help |= { HelpNode( - HelpPath( + HelpDoc( path=self.full_path() + '/' + help_info.path, - help=help_info.text, + help=help_info.description, ), [], help_node_type, @@ -310,7 +397,7 @@ class HandlerNode: custom_help |= {child.help_node() for child in self.children.values()} return HelpNode( - help=HelpPath(path=self.full_path(), help=self.handler.__doc__ or ''), + help=HelpDoc(path=self.full_path(), help=self.handler.__doc__ or ''), children=list(custom_help), kind=help_node_type, ) diff --git a/server/tests/REST/test_documentation.py b/server/tests/REST/test_documentation.py new file mode 100644 index 000000000..e69de29bb diff --git a/server/tests/core/types/__init__.py b/server/tests/core/types/__init__.py new file mode 100644 index 000000000..5b03820b6 --- /dev/null +++ b/server/tests/core/types/__init__.py @@ -0,0 +1,26 @@ +# +# Copyright (c) 2025 Virtual Cable S.L.U. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/server/tests/core/types/rest/__init__.py b/server/tests/core/types/rest/__init__.py new file mode 100644 index 000000000..5b03820b6 --- /dev/null +++ b/server/tests/core/types/rest/__init__.py @@ -0,0 +1,26 @@ +# +# Copyright (c) 2025 Virtual Cable S.L.U. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/server/tests/core/types/rest/test_helpdoc.py b/server/tests/core/types/rest/test_helpdoc.py new file mode 100644 index 000000000..37e690e7f --- /dev/null +++ b/server/tests/core/types/rest/test_helpdoc.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- + +# +# Copyright (c) 2022 Virtual Cable S.L.U. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +""" +Author: Adolfo Gómez, dkmaster at dkmon dot com +""" +import dataclasses +import logging +from unittest import TestCase + +from uds.core.types import rest + + +logger = logging.getLogger(__name__) + + +class TestHelpDoc(TestCase): + def test_helpdoc_basic(self) -> None: + h = rest.HelpDoc('/path', 'help_text') + + self.assertEqual(h.path, '/path') + self.assertEqual(h.description, 'help_text') + self.assertEqual(h.arguments, []) + + def test_helpdoc_with_args(self) -> None: + arguments = [ + rest.HelpDoc.ArgumentInfo('arg1', 'arg1_type', 'arg1_description'), + rest.HelpDoc.ArgumentInfo('arg2', 'arg2_type', 'arg2_description'), + ] + h = rest.HelpDoc( + '/path', + 'help_text', + arguments=arguments, + ) + + self.assertEqual(h.path, '/path') + self.assertEqual(h.description, 'help_text') + self.assertEqual(h.arguments, arguments) + + def test_helpdoc_with_args_and_return(self) -> None: + arguments = [ + rest.HelpDoc.ArgumentInfo('arg1', 'arg1_type', 'arg1_description'), + rest.HelpDoc.ArgumentInfo('arg2', 'arg2_type', 'arg2_description'), + ] + returns = { + 'name': 'return_name', + } + h = rest.HelpDoc( + '/path', + 'help_text', + arguments=arguments, + returns=returns, + ) + + self.assertEqual(h.path, '/path') + self.assertEqual(h.description, 'help_text') + self.assertEqual(h.arguments, arguments) + self.assertEqual(h.returns, returns) + + + def test_help_doc_from_typed_response(self) -> None: + @dataclasses.dataclass + class TestResponse(rest.TypedResponse): + name: str = 'test_name' + age: int = 0 + money: float = 0.0 + + h = rest.HelpDoc.from_typed_response('path', 'help', TestResponse) + + self.assertEqual(h.path, 'path') + self.assertEqual(h.description, 'help') + self.assertEqual(h.arguments, []) + self.assertEqual(h.returns, { + 'name': '', + 'age': '', + 'money': '', + })