Skip to content

Commit 2caf28e

Browse files
committed
Improved robustness of controllers
1 parent c1edcbf commit 2caf28e

File tree

7 files changed

+244
-70
lines changed

7 files changed

+244
-70
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from chartlets import Contribution
2+
from chartlets import ExtensionContext
3+
from chartlets import Response
4+
from chartlets.util.assertions import assert_is_not_empty
5+
from chartlets.util.assertions import assert_is_instance_of
6+
7+
8+
def get_contribution(
9+
ext_ctx: ExtensionContext,
10+
contrib_point_name: str,
11+
contrib_index: int,
12+
) -> tuple[Contribution, None] | tuple[None, Response]:
13+
"""Get the contribution for given `contrib_point_name` at `contrib_index`.
14+
15+
Args:
16+
ext_ctx: Extension context.
17+
contrib_point_name: Contribution point name.
18+
contrib_index: Contribution index.
19+
Returns:
20+
A pair comprising an optional `Contribution` and optional `Response` object.
21+
"""
22+
assert_is_instance_of("ext_ctx", ext_ctx, ExtensionContext)
23+
assert_is_not_empty("contrib_point_name", contrib_point_name)
24+
assert_is_instance_of("contrib_index", contrib_index, int)
25+
26+
try:
27+
contributions = ext_ctx.contributions[contrib_point_name]
28+
except KeyError:
29+
return None, Response.failed(
30+
404, f"contribution point {contrib_point_name!r} not found"
31+
)
32+
33+
try:
34+
contribution = contributions[contrib_index]
35+
except IndexError:
36+
return None, Response.failed(
37+
404,
38+
(
39+
f"index range of contribution point {contrib_point_name!r} is"
40+
f" 0 to {len(contributions) - 1}, got {contrib_index}"
41+
),
42+
)
43+
44+
return contribution, None

chartlets.py/chartlets/controllers/callback.py

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from chartlets.response import Response
55
from chartlets.util.assertions import assert_is_instance_of
66
from chartlets.util.assertions import assert_is_not_none
7+
from ._helpers import get_contribution
78

89

910
# POST /chartlets/callback
@@ -29,22 +30,42 @@ def get_callback_results(
2930
callback_requests: list[dict] = data.get("callbackRequests") or []
3031

3132
# TODO: assert correctness, set status code on error
32-
state_change_requests: list[dict] = []
33+
state_change_requests: list[dict[str, Any]] = []
3334
for callback_request in callback_requests:
3435
contrib_point_name: str = callback_request["contribPoint"]
3536
contrib_index: int = callback_request["contribIndex"]
3637
callback_index: int = callback_request["callbackIndex"]
3738
input_values: list = callback_request["inputValues"]
3839

39-
contributions = ext_ctx.contributions[contrib_point_name]
40-
contribution = contributions[contrib_index]
41-
callback = contribution.callbacks[callback_index]
40+
contribution, response = get_contribution(
41+
ext_ctx, contrib_point_name, contrib_index
42+
)
43+
if response is not None:
44+
return response
45+
46+
callbacks = contribution.callbacks
47+
if not callbacks:
48+
return Response.failed(
49+
400, f"contribution {contribution.name!r} has no callbacks"
50+
)
51+
52+
try:
53+
callback = callbacks[callback_index]
54+
except IndexError:
55+
return Response.failed(
56+
404,
57+
(
58+
f"index range of callbacks of contribution {contribution.name!r} is"
59+
f" 0 to {len(callbacks) - 1}, got {callback_index}"
60+
),
61+
)
62+
4263
output_values = callback.invoke(ext_ctx.app_ctx, input_values)
4364

4465
if len(callback.outputs) == 1:
4566
output_values = (output_values,)
4667

47-
state_changes: list[dict] = []
68+
state_changes: list[dict[str, Any]] = []
4869
for output_index, output in enumerate(callback.outputs):
4970
output_value = output_values[output_index]
5071
state_changes.append(
@@ -59,12 +80,23 @@ def get_callback_results(
5980
}
6081
)
6182

62-
state_change_requests.append(
63-
{
64-
"contribPoint": contrib_point_name,
65-
"contribIndex": contrib_index,
66-
"stateChanges": state_changes,
67-
}
68-
)
83+
existing_scr: dict[str, Any] | None = None
84+
for scr in state_change_requests:
85+
if (
86+
scr["contribPoint"] == contrib_point_name
87+
and scr["contribIndex"] == contrib_index
88+
):
89+
existing_scr = scr
90+
break
91+
if existing_scr is not None:
92+
existing_scr["stateChanges"].extend(state_changes)
93+
else:
94+
state_change_requests.append(
95+
{
96+
"contribPoint": contrib_point_name,
97+
"contribIndex": contrib_index,
98+
"stateChanges": state_changes,
99+
}
100+
)
69101

70102
return Response.success(state_change_requests)

chartlets.py/chartlets/controllers/layout.py

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
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
75
from chartlets.util.assertions import assert_is_instance_of
6+
from ._helpers import get_contribution
87

98

109
def get_layout(
@@ -28,31 +27,16 @@ def get_layout(
2827
On success, the response is a dictionary that represents
2928
a JSON-serialized component tree.
3029
"""
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)
3430
assert_is_instance_of("data", data, dict)
3531

3632
# TODO: validate data
3733
input_values = data.get("inputValues") or []
3834

39-
try:
40-
contributions = ext_ctx.contributions[contrib_point_name]
41-
except KeyError:
42-
return Response.failed(
43-
404, f"contribution point {contrib_point_name!r} not found"
44-
)
45-
46-
try:
47-
contribution = contributions[contrib_index]
48-
except IndexError:
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-
)
35+
contribution, response = get_contribution(
36+
ext_ctx, contrib_point_name, contrib_index
37+
)
38+
if response is not None:
39+
return response
5640

5741
callback = contribution.layout_callback
5842
if callback is None:

chartlets.py/chartlets/util/assertions.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,6 @@ def assert_is_instance_of(name: str, value: Any, type_set: Type | tuple[Type, ..
3030
type_set = (type_set,)
3131
raise TypeError(
3232
f"value of {name!r} must be of type"
33-
f" {" or ".join(map(lambda t: t.__name__, type_set))}, but was {type(value).__name__}"
33+
f" {" or ".join(map(lambda t: t.__name__, type_set))},"
34+
f" but was {'None' if value is None else type(value).__name__}"
3435
)

chartlets.py/tests/components/charts/vega_no_altair_test.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@ def test_no_altair(monkeypatch: pytest.MonkeyPatch):
1010
of "altair" gracefully.
1111
"""
1212
project_root = pathlib.Path(__file__).absolute().parent
13-
while project_root.parent != project_root and not (project_root / "chartlets" / "__init__.py").exists():
13+
while (
14+
project_root.parent != project_root
15+
and not (project_root / "chartlets" / "__init__.py").exists()
16+
):
1417
project_root = project_root.parent
1518

1619
# Simulate the absence of the 'altair' module
1720
print("project_root:", project_root)
1821
monkeypatch.setattr(sys, "path", [f"{project_root}"])
1922
if "altair" in sys.modules:
20-
monkeypatch.delitem(sys.modules,"altair")
23+
monkeypatch.delitem(sys.modules, "altair")
2124

2225
# Import the code that handles the missing "altair" package
2326
importlib.invalidate_caches()

0 commit comments

Comments
 (0)