diff --git a/dashi/src/model/callback.ts b/dashi/src/model/callback.ts index 7e79d971..44f7749b 100644 --- a/dashi/src/model/callback.ts +++ b/dashi/src/model/callback.ts @@ -1,9 +1,21 @@ export interface Callback { - function: string; + function: CbFunction; inputs?: Input[]; outputs?: Output[]; } +export interface CbFunction { + name: string; + parameters: CbParameter[]; + returnType: string | string[]; +} + +export interface CbParameter { + name: string; + type?: string | string[]; + default?: unknown; +} + export type InputOutputKind = "AppState" | "State" | "Component"; export interface InputOutput { diff --git a/dashipy/dashipy/lib/callback.py b/dashipy/dashipy/lib/callback.py index d5830bdd..c0f923db 100644 --- a/dashipy/dashipy/lib/callback.py +++ b/dashipy/dashipy/lib/callback.py @@ -1,7 +1,10 @@ import inspect +import types from abc import ABC from typing import Callable, Any, Literal +from .component import Component + ComponentKind = Literal["Component"] AppStateKind = Literal["AppState"] StateKind = Literal["State"] @@ -130,11 +133,19 @@ def invoke(self, context: Any, input_values: list | tuple): return self.function(*args, **kwargs) def to_dict(self) -> dict[str, Any]: - d = dict(function=self.function.__qualname__) + # skip ctx parameter: + parameters = list(self.signature.parameters.values())[1:] + d = { + "function": { + "name": self.function.__qualname__, + "parameters": [_parameter_to_dict(p) for p in parameters], + "returnType": _annotation_to_str(self.signature.return_annotation), + } + } if self.inputs: - d.update(inputs=[inp.to_dict() for inp in self.inputs]) + d.update({"inputs": [inp.to_dict() for inp in self.inputs]}) if self.outputs: - d.update(outputs=[out.to_dict() for out in self.outputs]) + d.update({"outputs": [out.to_dict() for out in self.outputs]}) return d def make_function_args( @@ -163,3 +174,65 @@ def make_function_args( kwargs[param_name] = param_value return tuple(args), kwargs + + +def _parameter_to_dict(parameter: inspect.Parameter) -> dict[str, Any]: + empty = inspect.Parameter.empty + d = {"name": parameter.name} + if parameter.annotation is not empty: + d |= {"type": _annotation_to_str(parameter.annotation)} + if parameter.default is not empty: + d |= {"default": parameter.default} + return d + + +_scalar_types = { + "None": "null", + "NoneType": "null", + "bool": "boolean", + "int": "integer", + "float": "float", + "str": "string", + "Component": "Component", +} + +_array_types = { + "list[bool]": "boolean[]", + "list[int]": "integer[]", + "list[float]": "float[]", + "list[str]": "string[]", + "list[Component]": "Component[]", +} + +_object_types = { + "Figure": "Figure", + "Component": "Component", +} + + +def _annotation_to_str(annotation: Any) -> str | list[str]: + if isinstance(annotation, types.UnionType): + type_name = str(annotation) + try: + return [_scalar_types[t] for t in type_name.split(" | ")] + except KeyError: + pass + elif isinstance(annotation, types.GenericAlias): + type_name = str(annotation) + try: + return _array_types[type_name] + except KeyError: + pass + else: + type_name = ( + annotation.__name__ if hasattr(annotation, "__name__") else str(annotation) + ) + try: + return _scalar_types[type_name] + except KeyError: + pass + try: + return _object_types[type_name] + except KeyError: + pass + raise TypeError(f"unsupported type: {type_name}") diff --git a/dashipy/tests/lib/callback_test.py b/dashipy/tests/lib/callback_test.py index 2b008b6d..8a4d6bab 100644 --- a/dashipy/tests/lib/callback_test.py +++ b/dashipy/tests/lib/callback_test.py @@ -5,11 +5,18 @@ from dashipy.lib.callback import Input, Callback -def my_callback(ctx, a: int, /, b: str = "", c: bool = False) -> str: - return f"{a}-{b}-{c}" +def my_callback( + ctx, + a: int, + /, + b: str | int = "", + c: bool | None = False, + d: list[str] = (), +) -> str: + return f"{a}-{b}-{c}-{d}" -class CallTest(unittest.TestCase): +class CallbackTest(unittest.TestCase): def test_make_function_args(self): callback = Callback(my_callback, [Input("a"), Input("b"), Input("c")], []) ctx = object() @@ -17,33 +24,54 @@ def test_make_function_args(self): self.assertEqual((ctx, 13), args) self.assertEqual({"b": "Wow", "c": True}, kwargs) + def test_to_dict(self): + callback = Callback( + my_callback, [Input("a"), Input("b"), Input("c"), Input("d")], [] + ) + d = callback.to_dict() + # print(json.dumps(d, indent=2)) + self.assertEqual( + { + "function": { + "name": "my_callback", + "parameters": [ + {"name": "a", "type": "integer"}, + {"name": "b", "type": ["string", "integer"], "default": ""}, + {"name": "c", "type": ["boolean", "null"], "default": False}, + {"name": "d", "type": "string[]", "default": ()}, + ], + "returnType": "string", + }, + "inputs": [ + {"id": "a", "property": "value", "kind": "Component"}, + {"id": "b", "property": "value", "kind": "Component"}, + {"id": "c", "property": "value", "kind": "Component"}, + {"id": "d", "property": "value", "kind": "Component"}, + ], + }, + d, + ) + # noinspection PyMethodMayBeStatic class FromDecoratorTest(unittest.TestCase): - def test_inputs_given_but_not_in_order(self): - callback = Callback.from_decorator( - "test", (Input("b"), Input("c"), Input("a")), my_callback - ) - self.assertIsInstance(callback, Callback) - self.assertIs(my_callback, callback.function) - self.assertEqual(3, len(callback.inputs)) - self.assertEqual(0, len(callback.outputs)) - def test_too_few_inputs(self): with pytest.raises( TypeError, - match="too few inputs in decorator 'test' for function 'my_callback': expected 3, but got 0", + match="too few inputs in decorator 'test' for function" + " 'my_callback': expected 4, but got 0", ): Callback.from_decorator("test", (), my_callback) def test_too_many_inputs(self): with pytest.raises( TypeError, - match="too many inputs in decorator 'test' for function 'my_callback': expected 3, but got 4", + match="too many inputs in decorator 'test' for function" + " 'my_callback': expected 4, but got 5", ): Callback.from_decorator( - "test", tuple(Input(c) for c in "abcd"), my_callback + "test", tuple(Input(c) for c in "abcde"), my_callback ) def test_decorator_target(self): @@ -57,12 +85,14 @@ def test_decorator_target(self): def test_decorator_args(self): with pytest.raises( TypeError, - match="arguments for decorator 'test' must be of type Input, but got 'int'", + match="arguments for decorator 'test' must be of" + " type Input, but got 'int'", ): Callback.from_decorator("test", (13,), my_callback) with pytest.raises( TypeError, - match="arguments for decorator 'test' must be of type Input or Output, but got 'int'", + match="arguments for decorator 'test' must be of" + " type Input or Output, but got 'int'", ): Callback.from_decorator("test", (13,), my_callback, outputs_allowed=True)