Skip to content

Commit 0d9142d

Browse files
committed
Python test coverage almost at 100%
1 parent e3b88f2 commit 0d9142d

29 files changed

+916
-132
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ node_modules
1212
__pycache__
1313
# mkdocs
1414
site
15+
# pytest --cov
16+
.coverage
1517

1618
# Editor directories and files
1719
.vscode/*

chartlets.js/CHANGES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
dark, light, and system mode.
3030

3131
* Changed the yet unused descriptor type `CbFunction` for callback functions.
32+
- using `schema` instead of `type` property for callback arguments
33+
- using `return` object with `schema` property for callback return values
34+
3235

3336
## Version 0.0.29 (from 2024/11/26)
3437

chartlets.py/CHANGES.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
## Version 0.1.0 (in development)
22

3-
43
* Reorganised Chartlets project to better separate demo from library code.
54
Created separate folder `demo` in `chartlets.py` that contains
65
a demo `server` package and example configuration.
@@ -14,6 +13,9 @@
1413
* Renamed `Plot` into `VegaChart`, which now also respects a `theme` property.
1514

1615
* Changed schema of the yet unused descriptor for callback functions.
16+
- using `schema` instead of `type` property for callback arguments
17+
- using `return` object with `schema` property for callback return values
18+
1719

1820
## Version 0.0.29 (from 2024/11/26)
1921

chartlets.py/chartlets/callback.py

Lines changed: 43 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
import types
33
from typing import Any, Callable
44

5-
from chartlets.channel import (
5+
from .channel import (
66
Input,
77
Output,
88
State,
99
)
10+
from .util.logger import LOGGER
1011

1112

1213
class Callback:
@@ -20,7 +21,7 @@ class Callback:
2021
def from_decorator(
2122
cls,
2223
decorator_name: str,
23-
decorator_args: tuple[Any, ...],
24+
decorator_args: tuple | list,
2425
function: Any,
2526
states_only: bool = False,
2627
) -> "Callback":
@@ -126,11 +127,10 @@ def make_function_args(
126127
f" expected {num_inputs},"
127128
f" but got {num_values}"
128129
)
129-
if delta > 0:
130-
values = (*values, *(delta * (None,)))
131-
print(f"WARNING: {message}") # TODO use logging
132-
else:
130+
if delta < 0:
133131
raise TypeError(message)
132+
LOGGER.warning(message)
133+
values = (*values, *(delta * (None,)))
134134

135135
param_names = self.param_names[1:]
136136
args = [context]
@@ -150,15 +150,14 @@ def _parameter_to_dict(parameter: inspect.Parameter) -> dict[str, Any]:
150150
empty = inspect.Parameter.empty
151151
d = {"name": parameter.name}
152152
if parameter.annotation is not empty:
153-
d |= {"schema": _annotation_to_json_schema(parameter.annotation)}
153+
d |= {"schema": annotation_to_json_schema(parameter.annotation)}
154154
if parameter.default is not empty:
155155
d |= {"default": parameter.default}
156156
return d
157157

158+
158159
def _return_to_dict(return_annotation: Any) -> dict[str, Any]:
159-
return {
160-
"schema": _annotation_to_json_schema(return_annotation)
161-
}
160+
return {"schema": annotation_to_json_schema(return_annotation)}
162161

163162

164163
_basic_types = {
@@ -173,68 +172,55 @@ def _return_to_dict(return_annotation: Any) -> dict[str, Any]:
173172
dict: "object",
174173
}
175174

176-
_object_types = {"Component": "Component", "Chart": "Chart"}
177175

176+
def annotation_to_json_schema(annotation: Any) -> dict:
177+
from chartlets import Component
178178

179-
def _annotation_to_json_schema(annotation: Any) -> dict:
180179
if annotation is Any:
181180
return {}
182-
183-
if annotation in _basic_types:
181+
elif annotation in _basic_types:
184182
return {"type": _basic_types[annotation]}
185-
186-
if isinstance(annotation, types.UnionType):
187-
type_list = list(map(_annotation_to_json_schema, annotation.__args__))
183+
elif isinstance(annotation, types.UnionType):
184+
assert annotation.__args__ and len(annotation.__args__) > 1
185+
type_list = list(map(annotation_to_json_schema, annotation.__args__))
188186
type_name_list = [
189-
t["type"] for t in type_list if isinstance(t.get("type"), str)
187+
t["type"]
188+
for t in type_list
189+
if isinstance(t.get("type"), str) and len(t) == 1
190190
]
191-
if len(type_name_list) == 1:
192-
return {"type": type_name_list[0]}
193-
elif len(type_name_list) > 1:
191+
if len(type_list) == len(type_name_list):
194192
return {"type": type_name_list}
195-
elif len(type_list) == 1:
196-
return type_list[0]
197-
elif len(type_list) > 1:
198-
return {"oneOf": type_list}
199193
else:
200-
return {}
201-
202-
if isinstance(annotation, types.GenericAlias):
194+
return {"oneOf": type_list}
195+
elif isinstance(annotation, types.GenericAlias):
196+
assert annotation.__args__
203197
if annotation.__origin__ is tuple:
204198
return {
205199
"type": "array",
206-
"items": list(map(_annotation_to_json_schema, annotation.__args__)),
200+
"items": list(map(annotation_to_json_schema, annotation.__args__)),
207201
}
208202
elif annotation.__origin__ is list:
209-
if annotation.__args__:
210-
return {
211-
"type": "array",
212-
"items": _annotation_to_json_schema(annotation.__args__[0]),
213-
}
203+
assert annotation.__args__ and len(annotation.__args__) == 1
204+
items_schema = annotation_to_json_schema(annotation.__args__[0])
205+
if items_schema == {}:
206+
return {"type": "array"}
214207
else:
215-
return {
216-
"type": "array",
217-
}
208+
return {"type": "array", "items": items_schema}
218209
elif annotation.__origin__ is dict:
219-
if annotation.__args__:
220-
if len(annotation.__args__) == 2 and annotation.__args__[0] is str:
221-
return {
222-
"type": "object",
223-
"additionalProperties": _annotation_to_json_schema(
224-
annotation.__args__[1]
225-
),
226-
}
227-
else:
228-
return {
229-
"type": "object",
230-
}
231-
else:
232-
type_name = (
233-
annotation.__name__ if hasattr(annotation, "__name__") else str(annotation)
234-
)
235-
try:
236-
return {"type": "object", "class": _object_types[type_name]}
237-
except KeyError:
238-
pass
210+
assert annotation.__args__
211+
assert len(annotation.__args__) == 2
212+
if annotation.__args__[0] is str:
213+
value_schema = annotation_to_json_schema(annotation.__args__[1])
214+
if value_schema == {}:
215+
return {"type": "object"}
216+
else:
217+
return {"type": "object", "additionalProperties": value_schema}
218+
elif (
219+
inspect.isclass(annotation)
220+
and "." not in annotation.__qualname__
221+
and callable(getattr(annotation, "to_dict", None))
222+
):
223+
# Note, for Component classes it is actually possible to generate the object schema
224+
return {"type": "object", "class": annotation.__qualname__}
239225

240226
raise TypeError(f"unsupported type annotation: {annotation}")

chartlets.py/chartlets/channel.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import Any
33

44
from .util.assertions import (
5-
assert_is_given,
5+
assert_is_not_empty,
66
assert_is_instance_of,
77
assert_is_one_of,
88
)
@@ -26,13 +26,13 @@ def to_dict(self) -> dict[str, Any]:
2626
return dict(id=self.id, property=self.property)
2727

2828
def _validate_params(self, id_: Any, property: Any) -> tuple[str, str | None]:
29-
assert_is_given("id", id_)
29+
assert_is_not_empty("id", id_)
3030
assert_is_instance_of("id", id_, str)
3131
id: str = id_
3232
if id.startswith("@"):
3333
# Other states than component states
3434
assert_is_one_of("id", id, ("@app", "@container"))
35-
assert_is_given("property", property)
35+
assert_is_not_empty("property", property)
3636
assert_is_instance_of("property", property, str)
3737
else:
3838
# Component state
@@ -44,7 +44,7 @@ def _validate_params(self, id_: Any, property: Any) -> tuple[str, str | None]:
4444
pass
4545
else:
4646
# Components must have valid properties
47-
assert_is_given("property", property)
47+
assert_is_not_empty("property", property)
4848
assert_is_instance_of("property", property, str)
4949
return id, property
5050

chartlets.py/chartlets/components/charts/vega.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,23 @@
22
from typing import Any
33
import warnings
44

5+
from chartlets import Component
6+
7+
58
# Respect that "altair" is an optional dependency.
9+
class AltairDummy:
10+
# noinspection PyPep8Naming
11+
@property
12+
def Chart(self):
13+
warnings.warn("you must install 'altair' to use the VegaChart component")
14+
return int
15+
16+
617
try:
718
# noinspection PyUnresolvedReferences
819
import altair
9-
10-
AltairChart = altair.Chart
1120
except ImportError:
12-
warnings.warn("you must install 'altair' to use the VegaChart component")
13-
AltairChart = type(None)
14-
15-
from chartlets import Component
21+
altair = AltairDummy()
1622

1723

1824
@dataclass(frozen=True)
@@ -27,7 +33,7 @@ class VegaChart(Component):
2733
theme: str | None = None
2834
"""The name of a [Vega theme](https://vega.github.io/vega-themes/)."""
2935

30-
chart: AltairChart | None = None
36+
chart: altair.Chart | None = None
3137
"""The [Vega Altair chart](https://altair-viz.github.io/gallery/index.html)."""
3238

3339
def to_dict(self) -> dict[str, Any]:

chartlets.py/chartlets/contribution.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ class Contribution(ABC):
1919
initial_state: contribution specific attribute values.
2020
"""
2121

22-
# noinspection PyShadowingBuiltins
2322
def __init__(self, name: str, **initial_state: Any):
2423
self.name = name
2524
self.initial_state = initial_state

chartlets.py/chartlets/controllers/callback.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from chartlets.extensioncontext import ExtensionContext
44
from chartlets.response import Response
5+
from chartlets.util.assertions import assert_is_instance_of
6+
from chartlets.util.assertions import assert_is_not_none
57

68

79
# POST /chartlets/callback
@@ -20,8 +22,8 @@ def get_callback_results(
2022
On success, the response is a list of state-change requests
2123
grouped by contributions.
2224
"""
23-
if ext_ctx is None:
24-
return Response.failed(404, f"no contributions configured")
25+
assert_is_not_none("ext_ctx", ext_ctx)
26+
assert_is_instance_of("data", data, dict)
2527

2628
# TODO: validate data
2729
callback_requests: list[dict] = data.get("callbackRequests") or []

chartlets.py/chartlets/controllers/contributions.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from chartlets.extensioncontext import ExtensionContext
22
from chartlets.response import Response
3+
from chartlets.util.assertions import assert_is_not_none
34

45

56
def get_contributions(ext_ctx: ExtensionContext | None) -> Response:
@@ -13,9 +14,7 @@ def get_contributions(ext_ctx: ExtensionContext | None) -> Response:
1314
On success, the response is a dictionary that represents
1415
a JSON-serialized component tree.
1516
"""
16-
if ext_ctx is None:
17-
return Response.failed(404, f"no contributions configured")
18-
17+
assert_is_not_none("ext_ctx", ext_ctx)
1918
extensions = ext_ctx.extensions
2019
contributions = ext_ctx.contributions
2120
return Response.success(

chartlets.py/chartlets/controllers/layout.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
from chartlets.extensioncontext import ExtensionContext
44
from chartlets.response import Response
5+
from chartlets.util.assertions import assert_is_not_none
6+
from chartlets.util.assertions import assert_is_not_empty
7+
from chartlets.util.assertions import assert_is_instance_of
58

69

710
def get_layout(
@@ -25,8 +28,10 @@ def get_layout(
2528
On success, the response is a dictionary that represents
2629
a JSON-serialized component tree.
2730
"""
28-
if ext_ctx is None:
29-
return Response.failed(404, f"no contributions configured")
31+
assert_is_not_none("ext_ctx", ext_ctx)
32+
assert_is_not_empty("contrib_point_name", contrib_point_name)
33+
assert_is_instance_of("contrib_index", contrib_index, int)
34+
assert_is_instance_of("data", data, dict)
3035

3136
# TODO: validate data
3237
input_values = data.get("inputValues") or []
@@ -38,16 +43,20 @@ def get_layout(
3843
404, f"contribution point {contrib_point_name!r} not found"
3944
)
4045

41-
contrib_ref = f"{contrib_point_name}[{contrib_index}]"
42-
4346
try:
4447
contribution = contributions[contrib_index]
4548
except IndexError:
46-
return Response.failed(404, f"contribution {contrib_ref!r} not found")
49+
return Response.failed(
50+
404,
51+
(
52+
f"index range of contribution point {contrib_point_name!r} is"
53+
f" 0 to {len(contributions) - 1}, got {contrib_index}"
54+
),
55+
)
4756

4857
callback = contribution.layout_callback
4958
if callback is None:
50-
return Response.failed(400, f"contribution {contrib_ref!r} has no layout")
59+
return Response.failed(400, f"contribution {contribution.name!r} has no layout")
5160

5261
component = callback.invoke(ext_ctx.app_ctx, input_values)
5362

0 commit comments

Comments
 (0)