Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Allow FeatureInputComponent (what-if inputs) to customize numeric ranges and rounding, and apply min/max/step to inputs.
- 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.
- Harden one-vs-all scorer handling so `make_one_vs_all_scorer` also accepts classifiers whose `predict_proba` returns a pandas `DataFrame`.
- 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.

## Version 0.5.6:

Expand Down
3 changes: 1 addition & 2 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
# TODO

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

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

**Next**
- [M][Hub][#269] add_dashboard endpoint fails after first request (Flask blueprint lifecycle).
- [M/L][Components][#262] add filters for random transaction selection in whatif tab.
- [S][Methods][#220] get_contrib_df accepts list/array input.
- [M][Components][#176] FeatureInputComponent hide parameters.
Expand Down
41 changes: 29 additions & 12 deletions explainerdashboard/dashboards.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import inspect
import requests
import logging
from contextlib import contextmanager
from typing import Dict, List, Optional, Union
from pathlib import Path
from copy import deepcopy
Expand Down Expand Up @@ -1715,6 +1716,18 @@ def remove_dashboard(self, dashboard_name):
if self.users and not self.dbs_open_by_default:
self._protect_dashviews(self.index_page)

@contextmanager
def _allow_dynamic_setup_after_first_request(self):
"""Temporarily bypass Flask setup lock for dynamic add_dashboard_route updates."""
original = getattr(self.app, "_got_first_request", None)
if original:
self.app._got_first_request = False
try:
yield
finally:
if original is not None:
self.app._got_first_request = original

def add_dashboard(self, dashboard: ExplainerDashboard, **kwargs):
"""Add a dashboard to the hub

Expand Down Expand Up @@ -1777,11 +1790,14 @@ def add_dashboard(self, dashboard: ExplainerDashboard, **kwargs):
config = deepcopy(dashboard.to_yaml(return_dict=True))
config["dashboard"]["params"]["logins"] = None

self.dashboards.append(
ExplainerDashboard.from_config(
dashboard.explainer, config, **update_kwargs(kwargs, **update_params)
with self._allow_dynamic_setup_after_first_request():
self.dashboards.append(
ExplainerDashboard.from_config(
dashboard.explainer,
config,
**update_kwargs(kwargs, **update_params),
)
)
)

self.dashboard_names.append(dashboard.name)

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

if self.users:
self.app.route(f"/{self.base_route}/_{dashboard.name}")(
login_required(dashboard_route(dashboard))
)
else:
self.app.route(f"/{self.base_route}/_{dashboard.name}")(
dashboard_route(dashboard)
)
with self._allow_dynamic_setup_after_first_request():
if self.users:
self.app.route(f"/{self.base_route}/_{dashboard.name}")(
login_required(dashboard_route(dashboard))
)
else:
self.app.route(f"/{self.base_route}/_{dashboard.name}")(
dashboard_route(dashboard)
)
return dashboard.name

@classmethod
Expand Down
35 changes: 34 additions & 1 deletion tests/hub/test_hub.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from explainerdashboard import ExplainerHub
from pathlib import Path

from explainerdashboard import ExplainerDashboard, ExplainerHub


def test_hub_users(explainer_hub):
Expand Down Expand Up @@ -36,3 +38,34 @@ def test_hub_to_zip(explainer_hub, tmp_path_factory):
tmp_path = tmp_path_factory.mktemp("tmp_hub")
explainer_hub.to_zip(tmp_path / "hub.zip")
assert (tmp_path / "hub.zip").exists()


def test_add_dashboard_route_after_first_request_adds_dashboard(
precalculated_rf_classifier_explainer,
precalculated_rf_regression_explainer,
tmp_path,
):
db1 = ExplainerDashboard(precalculated_rf_classifier_explainer, name="db1")
db2 = ExplainerDashboard(precalculated_rf_regression_explainer, name="db2")

explainer_path = tmp_path / "db2.joblib"
db2_yaml = tmp_path / "db2.yaml"
db2.explainer.dump(explainer_path)
db2.to_yaml(db2_yaml, explainerfile=str(explainer_path))

hub = ExplainerHub(
[db1],
users_file=str(Path.cwd() / "tests" / "test_assets" / "users.yaml"),
add_dashboard_route=True,
add_dashboard_pattern=str(tmp_path / "{}.yaml"),
)
hub.app.config["TESTING"] = True
client = hub.app.test_client()

# Simulate the app already serving requests before dynamic add.
response = client.get("/")
assert response.status_code in (200, 302)

response = client.get("/add_dashboard/db2")
assert response.status_code == 302
assert "db2" in hub.dashboard_names
Loading