From 94249decfb6855f003fc72677ea3418335bc9f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20G=C3=B3mez=20Garc=C3=ADa?= Date: Sat, 8 Feb 2025 20:07:15 +0100 Subject: [PATCH] Refactor HelpDoc class to support function-based help documentation and improve return type handling --- server/src/uds/REST/dispatcher.py | 15 --- server/src/uds/core/types/rest.py | 38 +++++- server/tests/core/types/rest/test_helpdoc.py | 120 ++++++++++++++++--- 3 files changed, 133 insertions(+), 40 deletions(-) diff --git a/server/src/uds/REST/dispatcher.py b/server/src/uds/REST/dispatcher.py index c0fd89bd4..72d503272 100644 --- a/server/src/uds/REST/dispatcher.py +++ b/server/src/uds/REST/dispatcher.py @@ -88,21 +88,6 @@ def dispatch( # # Guess content type from content type header (post) or ".xxx" to method content_type: str = request.META.get('CONTENT_TYPE', 'application/json').split(';')[0] - # while path: - # clean_path = path[0] - # # Skip empty path elements, so /x/y == /x////y for example (due to some bugs detected on some clients) - # if not clean_path: - # path = path[1:] - # continue - - # if clean_path in service.children: # if we have a node for this path, walk down - # service = service.children[clean_path] - # full_path_lst.append(path[0]) # Add this path to full path - # path = path[1:] # Remove first part of path - # else: - # break # If we don't have a node for this path, we are done - - # full_path = '/'.join(full_path_lst) handler_node = Dispatcher.base_handler_node.find_path(path) if not handler_node: return http.HttpResponseNotFound('Service not found', content_type="text/plain") diff --git a/server/src/uds/core/types/rest.py b/server/src/uds/core/types/rest.py index e56e0f5b2..6520cea27 100644 --- a/server/src/uds/core/types/rest.py +++ b/server/src/uds/core/types/rest.py @@ -72,7 +72,7 @@ def as_help(cls: type) -> dict[str, typing.Any]: 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)} @@ -170,19 +170,19 @@ class HelpDoc: """ Help helper class """ + @dataclasses.dataclass class ArgumentInfo: name: str type: str description: str - path: 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) + returns: typing.Any = None def __init__( self, @@ -218,15 +218,19 @@ def _process_help(self, help: str, annotations: typing.Optional[dict[str, typing """ self.description = '' self.arguments = [] - self.returns = {} + self.returns = None match = API_RE.search(help) if match: self.description = help[: match.start()].strip() if annotations: - if 'return' in annotations and issubclass(annotations['return'], TypedResponse): - self.returns = annotations['return'].as_help() + if 'return' in annotations: + t = annotations['return'] + if isinstance(t, collections.abc.Iterable): + pass + # if issubclass(annotations['return'], TypedResponse): + # self.returns = annotations['return'].as_help() @staticmethod def from_typed_response(path: str, help: str, TR: type[TypedResponse]) -> 'HelpDoc': @@ -239,6 +243,28 @@ def from_typed_response(path: str, help: str, TR: type[TypedResponse]) -> 'HelpD returns=TR.as_help(), ) + @staticmethod + def from_fnc(path: str, help: str, fnc: typing.Callable[..., typing.Any]) -> 'HelpDoc|None': + """ + Returns a HelpDoc from a function that returns a list of TypedResponses + """ + return_type: typing.Any = fnc.__annotations__.get('return') + + if isinstance(return_type, TypedResponse): + return HelpDoc.from_typed_response(path, help, typing.cast(type[TypedResponse], return_type)) + elif ( + isinstance(return_type, collections.abc.Iterable) + and len(typing.cast(typing.Any, return_type).__args__) == 1 + and issubclass(typing.cast(typing.Any, return_type).__args__[0], TypedResponse) + ): + hd = HelpDoc.from_typed_response( + path, help, typing.cast(type[TypedResponse], typing.cast(typing.Any, return_type).__args__[0]) + ) + hd.returns = [hd.returns] # We need to return a list of returns + return hd + + return None + @dataclasses.dataclass(frozen=True) class HelpNode: diff --git a/server/tests/core/types/rest/test_helpdoc.py b/server/tests/core/types/rest/test_helpdoc.py index 92b53650e..bc7ca82c3 100644 --- a/server/tests/core/types/rest/test_helpdoc.py +++ b/server/tests/core/types/rest/test_helpdoc.py @@ -30,7 +30,10 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com """ import dataclasses +import collections.abc import logging + +import typing from unittest import TestCase from uds.core.types import rest @@ -81,25 +84,27 @@ def test_helpdoc_with_args_and_return(self) -> None: 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': '', - }) + self.assertEqual( + h.returns, + { + 'name': '', + 'age': '', + 'money': '', + }, + ) def test_help_doc_from_typed_response_nested_dataclass(self) -> None: @dataclasses.dataclass @@ -107,26 +112,103 @@ class TestResponse: name: str = 'test_name' age: int = 0 money: float = 0.0 - + @dataclasses.dataclass class TestResponse2(rest.TypedResponse): name: str age: int money: float nested: TestResponse - + h = rest.HelpDoc.from_typed_response('path', 'help', TestResponse2) - + self.assertEqual(h.path, 'path') self.assertEqual(h.description, 'help') self.assertEqual(h.arguments, []) - self.assertEqual(h.returns, { - 'name': '', - 'age': '', - 'money': '', - 'nested': { + self.assertEqual( + h.returns, + { 'name': '', 'age': '', 'money': '', - } - }) \ No newline at end of file + 'nested': { + 'name': '', + 'age': '', + 'money': '', + }, + }, + ) + + def test_help_doc_from_fnc(self) -> None: + @dataclasses.dataclass + class TestResponse(rest.TypedResponse): + name: str = 'test_name' + age: int = 0 + money: float = 0.0 + + def testing_fnc() -> TestResponse: + """ + This is a test function + """ + return [] + + h = rest.HelpDoc.from_fnc('path', 'help', testing_fnc) + + if h is None: + self.fail('HelpDoc is None') + + self.assertEqual(h.path, 'path') + self.assertEqual(h.description, 'help') + self.assertEqual(h.arguments, []) + self.assertEqual( + h.returns, + { + 'name': '', + 'age': '', + 'money': '', + }, + ) + + def test_help_doc_from_non_typed_response(self) -> None: + def testing_fnc() -> dict[str, typing.Any]: + """ + This is a test function + """ + return {} + + h = rest.HelpDoc.from_fnc('path', 'help', testing_fnc) + + self.assertIsNone(h) + + + def test_help_doc_from_fnc_list(self) -> None: + @dataclasses.dataclass + class TestResponse(rest.TypedResponse): + name: str = 'test_name' + age: int = 0 + money: float = 0.0 + + def testing_fnc() -> list[TestResponse]: + """ + This is a test function + """ + return [] + + h = rest.HelpDoc.from_fnc('path', 'help', testing_fnc) + + if h is None: + self.fail('HelpDoc is None') + + self.assertEqual(h.path, 'path') + self.assertEqual(h.description, 'help') + self.assertEqual(h.arguments, []) + self.assertEqual( + h.returns, + [ + { + 'name': '', + 'age': '', + 'money': '', + } + ], + )