Skip to content

Commit e3566aa

Browse files
authored
Fix ExplainerHub add_dashboard_route after first request (#269) (#335)
1 parent ba1bd8c commit e3566aa

File tree

4 files changed

+65
-15
lines changed

4 files changed

+65
-15
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Allow FeatureInputComponent (what-if inputs) to customize numeric ranges and rounding, and apply min/max/step to inputs.
88
- Improve compatibility with AutoGluon/custom wrappers by coercing pandas `DataFrame` outputs from `predict_proba`/`predict` to numpy arrays before indexing in classifier/regression helper paths.
99
- Harden one-vs-all scorer handling so `make_one_vs_all_scorer` also accepts classifiers whose `predict_proba` returns a pandas `DataFrame`.
10+
- Fix ExplainerHub `add_dashboard_route` after first request by allowing dynamic dashboard registration/setup during route-triggered add, and add a regression test for issue #269.
1011

1112
## Version 0.5.6:
1213

TODO.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
# TODO
22

33
**Meta**
4-
- Last triage: 2026-02-05
4+
- Last triage: 2026-02-06
55
- Owner: @oegedijk
66
- Rules: link an issue when possible; include size S/M/L; mark blockers.
77

88
**Now**
99
- [M][Explainers][#273] categorical columns with NaNs: sorting and column preservation.
1010

1111
**Next**
12-
- [M][Hub][#269] add_dashboard endpoint fails after first request (Flask blueprint lifecycle).
1312
- [M/L][Components][#262] add filters for random transaction selection in whatif tab.
1413
- [S][Methods][#220] get_contrib_df accepts list/array input.
1514
- [M][Components][#176] FeatureInputComponent hide parameters.

explainerdashboard/dashboards.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import inspect
1919
import requests
2020
import logging
21+
from contextlib import contextmanager
2122
from typing import Dict, List, Optional, Union
2223
from pathlib import Path
2324
from copy import deepcopy
@@ -1715,6 +1716,18 @@ def remove_dashboard(self, dashboard_name):
17151716
if self.users and not self.dbs_open_by_default:
17161717
self._protect_dashviews(self.index_page)
17171718

1719+
@contextmanager
1720+
def _allow_dynamic_setup_after_first_request(self):
1721+
"""Temporarily bypass Flask setup lock for dynamic add_dashboard_route updates."""
1722+
original = getattr(self.app, "_got_first_request", None)
1723+
if original:
1724+
self.app._got_first_request = False
1725+
try:
1726+
yield
1727+
finally:
1728+
if original is not None:
1729+
self.app._got_first_request = original
1730+
17181731
def add_dashboard(self, dashboard: ExplainerDashboard, **kwargs):
17191732
"""Add a dashboard to the hub
17201733
@@ -1777,11 +1790,14 @@ def add_dashboard(self, dashboard: ExplainerDashboard, **kwargs):
17771790
config = deepcopy(dashboard.to_yaml(return_dict=True))
17781791
config["dashboard"]["params"]["logins"] = None
17791792

1780-
self.dashboards.append(
1781-
ExplainerDashboard.from_config(
1782-
dashboard.explainer, config, **update_kwargs(kwargs, **update_params)
1793+
with self._allow_dynamic_setup_after_first_request():
1794+
self.dashboards.append(
1795+
ExplainerDashboard.from_config(
1796+
dashboard.explainer,
1797+
config,
1798+
**update_kwargs(kwargs, **update_params),
1799+
)
17831800
)
1784-
)
17851801

17861802
self.dashboard_names.append(dashboard.name)
17871803

@@ -1804,14 +1820,15 @@ def inner():
18041820
inner.__name__ = "return_dashboard_" + dashboard.name
18051821
return inner
18061822

1807-
if self.users:
1808-
self.app.route(f"/{self.base_route}/_{dashboard.name}")(
1809-
login_required(dashboard_route(dashboard))
1810-
)
1811-
else:
1812-
self.app.route(f"/{self.base_route}/_{dashboard.name}")(
1813-
dashboard_route(dashboard)
1814-
)
1823+
with self._allow_dynamic_setup_after_first_request():
1824+
if self.users:
1825+
self.app.route(f"/{self.base_route}/_{dashboard.name}")(
1826+
login_required(dashboard_route(dashboard))
1827+
)
1828+
else:
1829+
self.app.route(f"/{self.base_route}/_{dashboard.name}")(
1830+
dashboard_route(dashboard)
1831+
)
18151832
return dashboard.name
18161833

18171834
@classmethod

tests/hub/test_hub.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from explainerdashboard import ExplainerHub
1+
from pathlib import Path
2+
3+
from explainerdashboard import ExplainerDashboard, ExplainerHub
24

35

46
def test_hub_users(explainer_hub):
@@ -36,3 +38,34 @@ def test_hub_to_zip(explainer_hub, tmp_path_factory):
3638
tmp_path = tmp_path_factory.mktemp("tmp_hub")
3739
explainer_hub.to_zip(tmp_path / "hub.zip")
3840
assert (tmp_path / "hub.zip").exists()
41+
42+
43+
def test_add_dashboard_route_after_first_request_adds_dashboard(
44+
precalculated_rf_classifier_explainer,
45+
precalculated_rf_regression_explainer,
46+
tmp_path,
47+
):
48+
db1 = ExplainerDashboard(precalculated_rf_classifier_explainer, name="db1")
49+
db2 = ExplainerDashboard(precalculated_rf_regression_explainer, name="db2")
50+
51+
explainer_path = tmp_path / "db2.joblib"
52+
db2_yaml = tmp_path / "db2.yaml"
53+
db2.explainer.dump(explainer_path)
54+
db2.to_yaml(db2_yaml, explainerfile=str(explainer_path))
55+
56+
hub = ExplainerHub(
57+
[db1],
58+
users_file=str(Path.cwd() / "tests" / "test_assets" / "users.yaml"),
59+
add_dashboard_route=True,
60+
add_dashboard_pattern=str(tmp_path / "{}.yaml"),
61+
)
62+
hub.app.config["TESTING"] = True
63+
client = hub.app.test_client()
64+
65+
# Simulate the app already serving requests before dynamic add.
66+
response = client.get("/")
67+
assert response.status_code in (200, 302)
68+
69+
response = client.get("/add_dashboard/db2")
70+
assert response.status_code == 302
71+
assert "db2" in hub.dashboard_names

0 commit comments

Comments
 (0)