From ee9ca13771f96f996e85bb1072e63238d9810c36 Mon Sep 17 00:00:00 2001 From: Oege Dijk Date: Fri, 6 Feb 2026 22:53:43 +0100 Subject: [PATCH] Fix ExplainerHub add_dashboard_route after first request (#269) --- RELEASE_NOTES.md | 1 + TODO.md | 3 +-- explainerdashboard/dashboards.py | 41 ++++++++++++++++++++++---------- tests/hub/test_hub.py | 35 ++++++++++++++++++++++++++- 4 files changed, 65 insertions(+), 15 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a376998..0ce4f7e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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: diff --git a/TODO.md b/TODO.md index 5a1b7b6..e5ac218 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,7 @@ # 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. @@ -9,7 +9,6 @@ - [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. diff --git a/explainerdashboard/dashboards.py b/explainerdashboard/dashboards.py index 122e446..4e1b31d 100644 --- a/explainerdashboard/dashboards.py +++ b/explainerdashboard/dashboards.py @@ -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 @@ -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 @@ -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) @@ -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 diff --git a/tests/hub/test_hub.py b/tests/hub/test_hub.py index 587088c..888ce52 100644 --- a/tests/hub/test_hub.py +++ b/tests/hub/test_hub.py @@ -1,4 +1,6 @@ -from explainerdashboard import ExplainerHub +from pathlib import Path + +from explainerdashboard import ExplainerDashboard, ExplainerHub def test_hub_users(explainer_hub): @@ -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