Skip to content

Commit

Permalink
Refactor HelpDoc class to support function-based help documentation a…
Browse files Browse the repository at this point in the history
…nd improve return type handling
  • Loading branch information
dkmstr committed Feb 8, 2025
1 parent f9a0026 commit 94249de
Show file tree
Hide file tree
Showing 3 changed files with 133 additions and 40 deletions.
15 changes: 0 additions & 15 deletions server/src/uds/REST/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
38 changes: 32 additions & 6 deletions server/src/uds/core/types/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def as_help(cls: type) -> dict[str, typing.Any]:
list: '<list>',
typing.Any: '<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)}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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':
Expand All @@ -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:
Expand Down
120 changes: 101 additions & 19 deletions server/tests/core/types/rest/test_helpdoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -81,52 +84,131 @@ 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': '<string>',
'age': '<integer>',
'money': '<float>',
})
self.assertEqual(
h.returns,
{
'name': '<string>',
'age': '<integer>',
'money': '<float>',
},
)

def test_help_doc_from_typed_response_nested_dataclass(self) -> None:
@dataclasses.dataclass
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': '<string>',
'age': '<integer>',
'money': '<float>',
'nested': {
self.assertEqual(
h.returns,
{
'name': '<string>',
'age': '<integer>',
'money': '<float>',
}
})
'nested': {
'name': '<string>',
'age': '<integer>',
'money': '<float>',
},
},
)

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': '<string>',
'age': '<integer>',
'money': '<float>',
},
)

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': '<string>',
'age': '<integer>',
'money': '<float>',
}
],
)

0 comments on commit 94249de

Please sign in to comment.