Skip to content

Commit 43e93e2

Browse files
committed
Fixed a bug deriving JSON schema
1 parent 8bd30e2 commit 43e93e2

File tree

6 files changed

+82
-63
lines changed

6 files changed

+82
-63
lines changed

chartlets.py/CHANGES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## Version 0.0.x (in development)
2+
3+
* Fixed a bug that prevent using annotations of type `dict` or `dict[str, T]`.
4+
in callback functions.
5+
6+
17
## Version 0.0.28 (from 2024/11/26)
28

39
* Updated docs.

chartlets.py/chartlets/callback.py

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -158,23 +158,45 @@ def _parameter_to_dict(parameter: inspect.Parameter) -> dict[str, Any]:
158158
return d
159159

160160

161-
_scalar_types = {
162-
"None": "null",
163-
"NoneType": "null",
164-
"bool": "boolean",
165-
"int": "integer",
166-
"float": "number",
167-
"str": "string",
168-
"object": "object",
161+
_basic_types = {
162+
None: "null",
163+
type(None): "null",
164+
bool: "boolean",
165+
int: "integer",
166+
float: "number",
167+
str: "string",
168+
list: "array",
169+
tuple: "array",
170+
dict: "object",
169171
}
170172

171173
_object_types = {"Component": "Component", "Chart": "Chart"}
172174

173175

174176
def _annotation_to_json_schema(annotation: Any) -> dict:
177+
if annotation is Any:
178+
return {}
179+
180+
if annotation in _basic_types:
181+
return {"type": _basic_types[annotation]}
182+
175183
if isinstance(annotation, types.UnionType):
176-
return {"type": list(map(_annotation_to_json_schema, annotation.__args__))}
177-
elif isinstance(annotation, types.GenericAlias):
184+
type_list = list(map(_annotation_to_json_schema, annotation.__args__))
185+
type_name_list = [
186+
t["type"] for t in type_list if isinstance(t.get("type"), str)
187+
]
188+
if len(type_name_list) == 1:
189+
return {"type": type_name_list[0]}
190+
elif len(type_name_list) > 1:
191+
return {"type": type_name_list}
192+
elif len(type_list) == 1:
193+
return type_list[0]
194+
elif len(type_list) > 1:
195+
return {"oneOf": type_list}
196+
else:
197+
return {}
198+
199+
if isinstance(annotation, types.GenericAlias):
178200
if annotation.__origin__ is tuple:
179201
return {
180202
"type": "array",
@@ -190,21 +212,26 @@ def _annotation_to_json_schema(annotation: Any) -> dict:
190212
return {
191213
"type": "array",
192214
}
193-
elif issubclass(annotation, list):
194-
try:
195-
return {"type": "array"}
196-
except KeyError:
197-
pass
215+
elif annotation.__origin__ is dict:
216+
if annotation.__args__:
217+
if len(annotation.__args__) == 2 and annotation.__args__[0] is str:
218+
return {
219+
"type": "object",
220+
"additionalProperties": _annotation_to_json_schema(
221+
annotation.__args__[1]
222+
),
223+
}
224+
else:
225+
return {
226+
"type": "object",
227+
}
198228
else:
199229
type_name = (
200230
annotation.__name__ if hasattr(annotation, "__name__") else str(annotation)
201231
)
202-
try:
203-
return {"type": _scalar_types[type_name]}
204-
except KeyError:
205-
pass
206232
try:
207233
return {"type": "object", "class": _object_types[type_name]}
208234
except KeyError:
209235
pass
236+
210237
raise TypeError(f"unsupported type annotation: {annotation}")

chartlets.py/chartlets/components/button.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class Button(Component):
2828
One "contained" | "outlined" | "text". Defaults to "text".
2929
"""
3030

31+
3132
@dataclass(frozen=True)
3233
class IconButton(Component):
3334
"""Icon buttons are commonly found in app bars and toolbars.
@@ -49,5 +50,3 @@ class IconButton(Component):
4950
"""The button variant.
5051
One "contained" | "outlined" | "text". Defaults to "text".
5152
"""
52-
53-

chartlets.py/chartlets/contribution.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,7 @@ def callback(self, *args: Input | State | Output) -> Callable[[Callable], Callab
123123

124124
def decorator(function: Callable) -> Callable:
125125
self.callbacks.append(
126-
Callback.from_decorator(
127-
"callback", args, function, states_only=False
128-
)
126+
Callback.from_decorator("callback", args, function, states_only=False)
129127
)
130128
return function
131129

chartlets.py/tests/callback_test.py

Lines changed: 26 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import unittest
2+
from typing import Any
23

34
import pytest
45

5-
from chartlets.callback import Input, Callback, Output
6+
from chartlets.channel import Input, State, Output
7+
from chartlets.callback import Callback
68

79

810
# noinspection PyUnusedLocal
@@ -13,6 +15,7 @@ def my_callback(
1315
b: str | int = "",
1416
c: bool | None = False,
1517
d: list[str] = (),
18+
e: dict[str, Any] = (),
1619
) -> str:
1720
return f"{a}-{b}-{c}-{d}"
1821

@@ -31,7 +34,9 @@ def test_make_function_args(self):
3134

3235
def test_to_dict_with_no_outputs(self):
3336
callback = Callback(
34-
my_callback, [Input("a"), Input("b"), Input("c"), Input("d")], []
37+
my_callback,
38+
[Input("a"), Input("b"), Input("c"), Input("d"), State("e")],
39+
[],
3540
)
3641
d = callback.to_dict()
3742
# print(json.dumps(d, indent=2))
@@ -44,38 +49,32 @@ def test_to_dict_with_no_outputs(self):
4449
{
4550
"default": "",
4651
"name": "b",
47-
"type": {"type": [{"type": "string"}, {"type": "integer"}]},
52+
"type": {"type": ["string", "integer"]},
4853
},
4954
{
5055
"default": False,
5156
"name": "c",
52-
"type": {"type": [{"type": "boolean"}, {"type": "null"}]},
57+
"type": {"type": ["boolean", "null"]},
5358
},
5459
{
5560
"default": (),
5661
"name": "d",
5762
"type": {"items": {"type": "string"}, "type": "array"},
5863
},
64+
{
65+
"default": (),
66+
"name": "e",
67+
"type": {"additionalProperties": {}, "type": "object"},
68+
},
5969
],
6070
"returnType": {"type": "string"},
6171
},
6272
"inputs": [
63-
{
64-
"id": "a",
65-
"property": "value",
66-
},
67-
{
68-
"id": "b",
69-
"property": "value",
70-
},
71-
{
72-
"id": "c",
73-
"property": "value",
74-
},
75-
{
76-
"id": "d",
77-
"property": "value",
78-
},
73+
{"id": "a", "property": "value"},
74+
{"id": "b", "property": "value"},
75+
{"id": "c", "property": "value"},
76+
{"id": "d", "property": "value"},
77+
{"id": "e", "noTrigger": True, "property": "value"},
7978
],
8079
},
8180
d,
@@ -100,26 +99,15 @@ def test_to_dict_with_two_outputs(self):
10099
"returnType": {
101100
"items": [
102101
{"items": {"type": "string"}, "type": "array"},
103-
{"type": [{"type": "string"}, {"type": "null"}]},
102+
{"type": ["string", "null"]},
104103
],
105104
"type": "array",
106105
},
107106
},
108-
"inputs": [
109-
{
110-
"id": "n",
111-
"property": "value",
112-
}
113-
],
107+
"inputs": [{"id": "n", "property": "value"}],
114108
"outputs": [
115-
{
116-
"id": "select",
117-
"property": "options",
118-
},
119-
{
120-
"id": "select",
121-
"property": "value",
122-
},
109+
{"id": "select", "property": "options"},
110+
{"id": "select", "property": "value"},
123111
],
124112
},
125113
d,
@@ -133,18 +121,18 @@ def test_too_few_inputs(self):
133121
with pytest.raises(
134122
TypeError,
135123
match="too few inputs in decorator 'test' for function"
136-
" 'my_callback': expected 4, but got 0",
124+
" 'my_callback': expected 5, but got 0",
137125
):
138126
Callback.from_decorator("test", (), my_callback)
139127

140128
def test_too_many_inputs(self):
141129
with pytest.raises(
142130
TypeError,
143131
match="too many inputs in decorator 'test' for function"
144-
" 'my_callback': expected 4, but got 5",
132+
" 'my_callback': expected 5, but got 7",
145133
):
146134
Callback.from_decorator(
147-
"test", tuple(Input(c) for c in "abcde"), my_callback
135+
"test", tuple(Input(c) for c in "abcdefg"), my_callback
148136
)
149137

150138
def test_decorator_target(self):

chartlets.py/tests/channel_test.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def test_to_dict(self):
8080

8181
def test_no_arguments(self):
8282
with pytest.raises(
83-
TypeError, match="missing 1 required positional argument: 'id'"
83+
TypeError, match="missing 1 required positional argument: 'id'"
8484
):
8585
# noinspection PyArgumentList
8686
obj = self.channel_cls()
@@ -108,6 +108,7 @@ def test_component_empty_property(self):
108108
with pytest.raises(ValueError, match="value for 'property' must be given"):
109109
self.channel_cls("dataset_select", "")
110110

111+
111112
class OutputTest(make_base(), unittest.TestCase):
112113
channel_cls = Output
113114

0 commit comments

Comments
 (0)