From 254a697de2db16d07a4d100295b6d89b1b2928ec Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 10 Apr 2026 23:57:10 -0700 Subject: [PATCH 1/5] Add missing stacklevel=2 to 9 warnings.warn() calls Without stacklevel, warnings.warn() reports the location of the warn() call itself rather than the caller's location, making it harder for users to identify the source of the warning in their own code. This adds stacklevel=2 to all 9 warnings.warn() calls across 7 files that were missing it. --- dash/_callback_context.py | 3 +++ dash/dash.py | 3 ++- dash/development/_jl_components_generation.py | 3 ++- dash/development/_r_components_generation.py | 3 ++- dash/development/base_component.py | 2 +- dash/resources.py | 3 ++- dash/testing/browser.py | 2 +- 7 files changed, 13 insertions(+), 6 deletions(-) diff --git a/dash/_callback_context.py b/dash/_callback_context.py index 09faf6f9a3..f4e12693a7 100644 --- a/dash/_callback_context.py +++ b/dash/_callback_context.py @@ -177,6 +177,7 @@ def outputs_list(self): warnings.warn( "outputs_list is deprecated, use outputs_grouping instead", DeprecationWarning, + stacklevel=2, ) return getattr(_get_context_value(), "outputs_list", []) @@ -188,6 +189,7 @@ def inputs_list(self): warnings.warn( "inputs_list is deprecated, use args_grouping instead", DeprecationWarning, + stacklevel=2, ) return getattr(_get_context_value(), "inputs_list", []) @@ -199,6 +201,7 @@ def states_list(self): warnings.warn( "states_list is deprecated, use args_grouping instead", DeprecationWarning, + stacklevel=2, ) return getattr(_get_context_value(), "states_list", []) diff --git a/dash/dash.py b/dash/dash.py index 122cf54dd6..5666dc0ce7 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -643,7 +643,8 @@ def __init__( # pylint: disable=too-many-statements if self.__class__.__name__ == "JupyterDash": warnings.warn( "JupyterDash is deprecated, use Dash instead.\n" - "See https://dash.plotly.com/dash-in-jupyter for more details." + "See https://dash.plotly.com/dash-in-jupyter for more details.", + stacklevel=2, ) self.setup_startup_routes() diff --git a/dash/development/_jl_components_generation.py b/dash/development/_jl_components_generation.py index 771ac0180a..4b7a7be4e1 100644 --- a/dash/development/_jl_components_generation.py +++ b/dash/development/_jl_components_generation.py @@ -468,7 +468,8 @@ def generate_class_string(name, props, description, project_shortname, prefix): ( 'WARNING: prop "{}" in component "{}" is a Julia keyword' " - REMOVED FROM THE JULIA COMPONENT" - ).format(item, name) + ).format(item, name), + stacklevel=2, ) default_paramtext += ", ".join(":{}".format(p) for p in prop_keys) diff --git a/dash/development/_r_components_generation.py b/dash/development/_r_components_generation.py index 18ac60d5d8..e392dc9aca 100644 --- a/dash/development/_r_components_generation.py +++ b/dash/development/_r_components_generation.py @@ -216,7 +216,8 @@ def generate_class_string(name, props, project_shortname, prefix): ( 'WARNING: prop "{}" in component "{}" is an R keyword' " - REMOVED FROM THE R COMPONENT" - ).format(item, name) + ).format(item, name), + stacklevel=2, ) default_argtext += ", ".join("{}=NULL".format(p) for p in prop_keys) diff --git a/dash/development/base_component.py b/dash/development/base_component.py index 02579ff2e2..0e93af5e3e 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -451,7 +451,7 @@ def _validate_deprecation(self): _ns = getattr(self, "_namespace", "") deprecation_message = _deprecated_components.get(_ns, {}).get(_type) if deprecation_message: - warnings.warn(DeprecationWarning(textwrap.dedent(deprecation_message))) + warnings.warn(DeprecationWarning(textwrap.dedent(deprecation_message)), stacklevel=2) ComponentSingleType = typing.Union[str, int, float, Component, None] diff --git a/dash/resources.py b/dash/resources.py index e197b6753e..62fddb24af 100644 --- a/dash/resources.py +++ b/dash/resources.py @@ -111,7 +111,8 @@ def _filter_resources( "or `app.css.append_css`, use `external_scripts` " "or `external_stylesheets` instead.\n" "See https://dash.plotly.com/external-resources" - ) + ), + stacklevel=2, ) continue else: diff --git a/dash/testing/browser.py b/dash/testing/browser.py index 8a82399a97..09af45c76d 100644 --- a/dash/testing/browser.py +++ b/dash/testing/browser.py @@ -624,7 +624,7 @@ def get_logs(self): for entry in self.driver.get_log("browser") if entry["timestamp"] > self._last_ts ] - warnings.warn("get_logs always return None with webdrivers other than Chrome") + warnings.warn("get_logs always return None with webdrivers other than Chrome", stacklevel=2) return None def reset_log_timestamp(self): From 081281d73f4db04c26a5b1a02c29f4d83a8365a7 Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 17 Apr 2026 01:17:44 -0700 Subject: [PATCH 2/5] Run black formatter and add changelog entry --- CHANGELOG.md | 1 + dash/_callback_context.py | 7 +-- dash/dash.py | 58 ++++++++++--------- dash/development/_jl_components_generation.py | 8 ++- dash/development/_r_components_generation.py | 1 - dash/development/base_component.py | 38 +++++------- dash/resources.py | 13 ++--- dash/testing/browser.py | 18 +++--- 8 files changed, 65 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a78eabf786..46217dfe61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Fixed - [#3690](https://github.com/plotly/dash/pull/3690) Fixes Input when min or max is set to None - [#3723](https://github.com/plotly/dash/pull/3723) Fix misaligned `dcc.Slider` marks when some labels are empty strings +- [#3738](https://github.com/plotly/dash/pull/3738) Add missing `stacklevel=2` to `warnings.warn()` calls so warnings report the caller's location instead of internal Dash source lines ## [4.1.0] - 2026-03-23 diff --git a/dash/_callback_context.py b/dash/_callback_context.py index f4e12693a7..81e06036fb 100644 --- a/dash/_callback_context.py +++ b/dash/_callback_context.py @@ -9,10 +9,9 @@ from . import exceptions from ._utils import AttributeDict, stringify_id - -context_value: contextvars.ContextVar[ - typing.Dict[str, typing.Any] -] = contextvars.ContextVar("callback_context") +context_value: contextvars.ContextVar[typing.Dict[str, typing.Any]] = ( + contextvars.ContextVar("callback_context") +) context_value.set({}) diff --git a/dash/dash.py b/dash/dash.py index 5666dc0ce7..6ba3979ef2 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1118,9 +1118,11 @@ def _generate_css_dist_html(self): return "\n".join( [ - format_tag("link", link, opened=True) - if isinstance(link, dict) - else f'' + ( + format_tag("link", link, opened=True) + if isinstance(link, dict) + else f'' + ) for link in (external_links + links) ] ) @@ -1174,9 +1176,11 @@ def _generate_scripts_html(self) -> str: return "\n".join( [ - format_tag("script", src) - if isinstance(src, dict) - else f'' + ( + format_tag("script", src) + if isinstance(src, dict) + else f'' + ) for src in srcs ] + [f"" for src in self._inline_scripts] @@ -1653,9 +1657,11 @@ def _setup_server(self): # For each callback function, if the hidden parameter uses the default value None, # replace it with the actual value of the self.config.hide_all_callbacks. self._callback_list = [ - {**_callback, "hidden": self.config.get("hide_all_callbacks", False)} - if _callback.get("hidden") is None - else _callback + ( + {**_callback, "hidden": self.config.get("hide_all_callbacks", False)} + if _callback.get("hidden") is None + else _callback + ) for _callback in self._callback_list ] @@ -2165,9 +2171,7 @@ def enable_dev_tools( # pylint: disable=too-many-branches pkg_dir = ( package.submodule_search_locations[0] if package.submodule_search_locations - else os.path.dirname(package.origin) - if package.origin - else None + else os.path.dirname(package.origin) if package.origin else None ) if pkg_dir and "dash/dash" in pkg_dir: component_packages_dist[i : i + 1] = [ @@ -2496,14 +2500,12 @@ def run( def verify_url_part(served_part, url_part, part_name): if served_part != url_part: - raise ProxyError( - f""" + raise ProxyError(f""" {part_name}: {url_part} is incompatible with the proxy: {proxy} To see your app at {proxied_url.geturl()}, you must use {part_name}: {served_part} - """ - ) + """) verify_url_part(served_url.scheme, protocol, "protocol") verify_url_part(served_url.hostname, host, "host") @@ -2615,16 +2617,16 @@ async def update(pathname_, search_, **states): if not self.config.suppress_callback_exceptions: self.validation_layout = html.Div( [ - asyncio.run(execute_async_function(page["layout"])) - if callable(page["layout"]) - else page["layout"] + ( + asyncio.run(execute_async_function(page["layout"])) + if callable(page["layout"]) + else page["layout"] + ) for page in _pages.PAGE_REGISTRY.values() ] + [ # pylint: disable=not-callable - self.layout() - if callable(self.layout) - else self.layout + self.layout() if callable(self.layout) else self.layout ] ) if _ID_CONTENT not in self.validation_layout: @@ -2681,15 +2683,15 @@ def update(pathname_, search_, **states): if not isinstance(layout, list): layout = [ # pylint: disable=not-callable - self.layout() - if callable(self.layout) - else self.layout + self.layout() if callable(self.layout) else self.layout ] self.validation_layout = html.Div( [ - page["layout"]() - if callable(page["layout"]) - else page["layout"] + ( + page["layout"]() + if callable(page["layout"]) + else page["layout"] + ) for page in _pages.PAGE_REGISTRY.values() ] + layout diff --git a/dash/development/_jl_components_generation.py b/dash/development/_jl_components_generation.py index 4b7a7be4e1..b9b5aec21a 100644 --- a/dash/development/_jl_components_generation.py +++ b/dash/development/_jl_components_generation.py @@ -355,9 +355,11 @@ def nothing_or_string(v): external_url=nothing_or_string(resource.get("external_url", "")), dynamic=str(resource.get("dynamic", "nothing")).lower(), type=metatype, - async_string=":{}".format(str(resource.get("async")).lower()) - if "async" in resource.keys() - else "nothing", + async_string=( + ":{}".format(str(resource.get("async")).lower()) + if "async" in resource.keys() + else "nothing" + ), ) for resource in resources ] diff --git a/dash/development/_r_components_generation.py b/dash/development/_r_components_generation.py index e392dc9aca..54d7b04dc2 100644 --- a/dash/development/_r_components_generation.py +++ b/dash/development/_r_components_generation.py @@ -11,7 +11,6 @@ from ._all_keywords import r_keywords from ._py_components_generation import reorder_props - # Declaring longer string templates as globals to improve # readability, make method logic clearer to anyone inspecting # code below diff --git a/dash/development/base_component.py b/dash/development/base_component.py index 0e93af5e3e..eb37a58359 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -15,22 +15,14 @@ rd = random.Random(0) _deprecated_components = { - "dash_core_components": { - "LogoutButton": textwrap.dedent( - """ + "dash_core_components": {"LogoutButton": textwrap.dedent(""" The Logout Button is no longer used with Dash Enterprise and can be replaced with a html.Button or html.A. eg: html.A(href=os.getenv('DASH_LOGOUT_URL')) - """ - ) - }, - "dash_table": { - "DataTable": textwrap.dedent( - """ + """)}, + "dash_table": {"DataTable": textwrap.dedent(""" The dash_table.DataTable will be removed from the builtin dash components in a future major version. We recommend using dash-ag-grid as a replacement. Install with `pip install dash[ag-grid]`. - """ - ) - }, + """)}, } @@ -39,9 +31,9 @@ class ComponentRegistry: """Holds a registry of the namespaces used by components.""" registry = OrderedSet() - children_props: typing.DefaultDict[ - str, typing.Dict[str, typing.Any] - ] = collections.defaultdict(dict) + children_props: typing.DefaultDict[str, typing.Dict[str, typing.Any]] = ( + collections.defaultdict(dict) + ) namespace_to_package: typing.Dict[str, str] = {} @classmethod @@ -221,26 +213,22 @@ def _set_random_id(self): kind = f"`{self._namespace}.{self._type}`" # pylint: disable=no-member if getattr(self, "persistence", False): - raise RuntimeError( - f""" + raise RuntimeError(f""" Attempting to use an auto-generated ID with the `persistence` prop. This is prohibited because persistence is tied to component IDs and auto-generated IDs can easily change. Please assign an explicit ID to this {kind} component. - """ - ) + """) if "dash_snapshots" in sys.modules: - raise RuntimeError( - f""" + raise RuntimeError(f""" Attempting to use an auto-generated ID in an app with `dash_snapshots`. This is prohibited because snapshots saves the whole app layout, including component IDs, and auto-generated IDs can easily change. Callbacks referencing the new IDs will not work with old snapshots. Please assign an explicit ID to this {kind} component. - """ - ) + """) v = str(uuid.UUID(int=rd.randint(0, 2**128))) setattr(self, "id", v) @@ -451,7 +439,9 @@ def _validate_deprecation(self): _ns = getattr(self, "_namespace", "") deprecation_message = _deprecated_components.get(_ns, {}).get(_type) if deprecation_message: - warnings.warn(DeprecationWarning(textwrap.dedent(deprecation_message)), stacklevel=2) + warnings.warn( + DeprecationWarning(textwrap.dedent(deprecation_message)), stacklevel=2 + ) ComponentSingleType = typing.Union[str, int, float, Component, None] diff --git a/dash/resources.py b/dash/resources.py index 62fddb24af..cc392701b1 100644 --- a/dash/resources.py +++ b/dash/resources.py @@ -9,7 +9,6 @@ from .development.base_component import ComponentRegistry from . import exceptions - # ResourceType has `async` key, use the init form to be able to provide it. ResourceType = _tx.TypedDict( "ResourceType", @@ -59,12 +58,10 @@ def _filter_resources( filtered_resource["dynamic"] = s["dynamic"] if "async" in s: if "dynamic" in s: - raise exceptions.ResourceException( - f""" + raise exceptions.ResourceException(f""" Can't have both 'dynamic' and 'async'. {json.dumps(filtered_resource)} - """ - ) + """) # Async assigns a value dynamically to 'dynamic' # based on the value of 'async' and config.eager_loading @@ -116,12 +113,10 @@ def _filter_resources( ) continue else: - raise exceptions.ResourceException( - f""" + raise exceptions.ResourceException(f""" {json.dumps(filtered_resource)} does not have a relative_package_path, absolute_path, or an external_url. - """ - ) + """) if valid_resource: filtered_resources.append(filtered_resource) diff --git a/dash/testing/browser.py b/dash/testing/browser.py index 09af45c76d..05af2ec1f4 100644 --- a/dash/testing/browser.py +++ b/dash/testing/browser.py @@ -33,7 +33,6 @@ from dash.testing.errors import DashAppLoadingError, BrowserError, TestingTimeoutError from dash.testing.consts import SELENIUM_GRID_DEFAULT - logger = logging.getLogger(__name__) @@ -187,8 +186,7 @@ def percy_snapshot( ) if convert_canvases: - self.driver.execute_script( - """ + self.driver.execute_script(""" const stash = window._canvasStash = []; Array.from(document.querySelectorAll('canvas')).forEach(c => { const i = document.createElement('img'); @@ -202,8 +200,7 @@ def percy_snapshot( c.parentElement.insertBefore(i, c); c.parentElement.removeChild(c); }); - """ - ) + """) try: self.percy_runner.snapshot(name=name, widths=widths) @@ -213,8 +210,7 @@ def percy_snapshot( raise err if convert_canvases: - self.driver.execute_script( - """ + self.driver.execute_script(""" const stash = window._canvasStash; Array.from( document.querySelectorAll('img[data-canvasnum]') @@ -224,8 +220,7 @@ def percy_snapshot( i.parentElement.removeChild(i); }); delete window._canvasStash; - """ - ) + """) def take_snapshot(self, name: str): """Hook method to take snapshot when a selenium test fails. The @@ -624,7 +619,10 @@ def get_logs(self): for entry in self.driver.get_log("browser") if entry["timestamp"] > self._last_ts ] - warnings.warn("get_logs always return None with webdrivers other than Chrome", stacklevel=2) + warnings.warn( + "get_logs always return None with webdrivers other than Chrome", + stacklevel=2, + ) return None def reset_log_timestamp(self): From d2b77b07b5bfbac00e9b3b15a31345b673e47816 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 6 May 2026 14:39:10 -0400 Subject: [PATCH 3/5] format --- dash/_callback_context.py | 6 +++--- dash/dash.py | 18 +++++++++++----- dash/development/base_component.py | 34 ++++++++++++++++++++---------- dash/resources.py | 12 +++++++---- dash/testing/browser.py | 12 +++++++---- 5 files changed, 55 insertions(+), 27 deletions(-) diff --git a/dash/_callback_context.py b/dash/_callback_context.py index 81e06036fb..0f4d1a9924 100644 --- a/dash/_callback_context.py +++ b/dash/_callback_context.py @@ -9,9 +9,9 @@ from . import exceptions from ._utils import AttributeDict, stringify_id -context_value: contextvars.ContextVar[typing.Dict[str, typing.Any]] = ( - contextvars.ContextVar("callback_context") -) +context_value: contextvars.ContextVar[ + typing.Dict[str, typing.Any] +] = contextvars.ContextVar("callback_context") context_value.set({}) diff --git a/dash/dash.py b/dash/dash.py index b40a6544eb..91c02e4867 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -2193,7 +2193,9 @@ def enable_dev_tools( # pylint: disable=too-many-branches pkg_dir = ( package.submodule_search_locations[0] if package.submodule_search_locations - else os.path.dirname(package.origin) if package.origin else None + else os.path.dirname(package.origin) + if package.origin + else None ) if pkg_dir and "dash/dash" in pkg_dir: component_packages_dist[i : i + 1] = [ @@ -2522,12 +2524,14 @@ def run( def verify_url_part(served_part, url_part, part_name): if served_part != url_part: - raise ProxyError(f""" + raise ProxyError( + f""" {part_name}: {url_part} is incompatible with the proxy: {proxy} To see your app at {proxied_url.geturl()}, you must use {part_name}: {served_part} - """) + """ + ) verify_url_part(served_url.scheme, protocol, "protocol") verify_url_part(served_url.hostname, host, "host") @@ -2648,7 +2652,9 @@ async def update(pathname_, search_, **states): ] + [ # pylint: disable=not-callable - self.layout() if callable(self.layout) else self.layout + self.layout() + if callable(self.layout) + else self.layout ] ) if _ID_CONTENT not in self.validation_layout: @@ -2705,7 +2711,9 @@ def update(pathname_, search_, **states): if not isinstance(layout, list): layout = [ # pylint: disable=not-callable - self.layout() if callable(self.layout) else self.layout + self.layout() + if callable(self.layout) + else self.layout ] self.validation_layout = html.Div( [ diff --git a/dash/development/base_component.py b/dash/development/base_component.py index eb37a58359..342ad6da6f 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -15,14 +15,22 @@ rd = random.Random(0) _deprecated_components = { - "dash_core_components": {"LogoutButton": textwrap.dedent(""" + "dash_core_components": { + "LogoutButton": textwrap.dedent( + """ The Logout Button is no longer used with Dash Enterprise and can be replaced with a html.Button or html.A. eg: html.A(href=os.getenv('DASH_LOGOUT_URL')) - """)}, - "dash_table": {"DataTable": textwrap.dedent(""" + """ + ) + }, + "dash_table": { + "DataTable": textwrap.dedent( + """ The dash_table.DataTable will be removed from the builtin dash components in a future major version. We recommend using dash-ag-grid as a replacement. Install with `pip install dash[ag-grid]`. - """)}, + """ + ) + }, } @@ -31,9 +39,9 @@ class ComponentRegistry: """Holds a registry of the namespaces used by components.""" registry = OrderedSet() - children_props: typing.DefaultDict[str, typing.Dict[str, typing.Any]] = ( - collections.defaultdict(dict) - ) + children_props: typing.DefaultDict[ + str, typing.Dict[str, typing.Any] + ] = collections.defaultdict(dict) namespace_to_package: typing.Dict[str, str] = {} @classmethod @@ -213,22 +221,26 @@ def _set_random_id(self): kind = f"`{self._namespace}.{self._type}`" # pylint: disable=no-member if getattr(self, "persistence", False): - raise RuntimeError(f""" + raise RuntimeError( + f""" Attempting to use an auto-generated ID with the `persistence` prop. This is prohibited because persistence is tied to component IDs and auto-generated IDs can easily change. Please assign an explicit ID to this {kind} component. - """) + """ + ) if "dash_snapshots" in sys.modules: - raise RuntimeError(f""" + raise RuntimeError( + f""" Attempting to use an auto-generated ID in an app with `dash_snapshots`. This is prohibited because snapshots saves the whole app layout, including component IDs, and auto-generated IDs can easily change. Callbacks referencing the new IDs will not work with old snapshots. Please assign an explicit ID to this {kind} component. - """) + """ + ) v = str(uuid.UUID(int=rd.randint(0, 2**128))) setattr(self, "id", v) diff --git a/dash/resources.py b/dash/resources.py index cc392701b1..bb55328b3b 100644 --- a/dash/resources.py +++ b/dash/resources.py @@ -58,10 +58,12 @@ def _filter_resources( filtered_resource["dynamic"] = s["dynamic"] if "async" in s: if "dynamic" in s: - raise exceptions.ResourceException(f""" + raise exceptions.ResourceException( + f""" Can't have both 'dynamic' and 'async'. {json.dumps(filtered_resource)} - """) + """ + ) # Async assigns a value dynamically to 'dynamic' # based on the value of 'async' and config.eager_loading @@ -113,10 +115,12 @@ def _filter_resources( ) continue else: - raise exceptions.ResourceException(f""" + raise exceptions.ResourceException( + f""" {json.dumps(filtered_resource)} does not have a relative_package_path, absolute_path, or an external_url. - """) + """ + ) if valid_resource: filtered_resources.append(filtered_resource) diff --git a/dash/testing/browser.py b/dash/testing/browser.py index 55d5d3f0d4..bbe92dceb6 100644 --- a/dash/testing/browser.py +++ b/dash/testing/browser.py @@ -178,7 +178,8 @@ def percy_snapshot( ) if convert_canvases: - self.driver.execute_script(""" + self.driver.execute_script( + """ const stash = window._canvasStash = []; Array.from(document.querySelectorAll('canvas')).forEach(c => { const i = document.createElement('img'); @@ -192,7 +193,8 @@ def percy_snapshot( c.parentElement.insertBefore(i, c); c.parentElement.removeChild(c); }); - """) + """ + ) # NEW: Use percy-python-selenium SDK try: @@ -205,7 +207,8 @@ def percy_snapshot( logger.warning("Percy snapshot failed: %s", err) if convert_canvases: - self.driver.execute_script(""" + self.driver.execute_script( + """ const stash = window._canvasStash; Array.from( document.querySelectorAll('img[data-canvasnum]') @@ -215,7 +218,8 @@ def percy_snapshot( i.parentElement.removeChild(i); }); delete window._canvasStash; - """) + """ + ) def take_snapshot(self, name: str): """Hook method to take snapshot when a selenium test fails. The From ea152fea998094f9fb0a98e7b4b19aa8280315be Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 6 May 2026 15:34:42 -0400 Subject: [PATCH 4/5] post-test-status in workflow dispatch --- .github/workflows/post-test-status.yml | 72 ++++++++++++++++++++++++++ .github/workflows/testing.yml | 24 --------- 2 files changed, 72 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/post-test-status.yml diff --git a/.github/workflows/post-test-status.yml b/.github/workflows/post-test-status.yml new file mode 100644 index 0000000000..428295c11f --- /dev/null +++ b/.github/workflows/post-test-status.yml @@ -0,0 +1,72 @@ +name: Post Test Status + +# This workflow runs in the base repo context (with write permissions) +# even for fork PRs, allowing us to post commit statuses. +# +# Add any status posting for skipped jobs here to avoid "Resource not +# accessible by integration" errors when PRs come from forks. + +on: + workflow_run: + workflows: ["Dash Testing"] + types: + - completed + +jobs: + post-skipped-statuses: + name: Post Statuses for Skipped Jobs + runs-on: ubuntu-latest + if: github.event.workflow_run.event == 'pull_request' + permissions: + statuses: write + actions: read + steps: + - name: Post statuses for skipped jobs + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const runId = context.payload.workflow_run.id; + const headSha = context.payload.workflow_run.head_sha; + + // Define jobs that need a success status posted when skipped + // Format: { jobName: 'GitHub Job Name', statusContext: 'status/context-name', description: 'Description' } + const skippedStatusJobs = [ + { + jobName: 'Dash Table Visual Tests', + statusContext: 'percy/dash-table-test', + description: 'Skipped — no dash-table changes' + } + // Add more jobs here as needed: + // { + // jobName: 'Job Name', + // statusContext: 'context/name', + // description: 'Skipped — reason' + // } + ]; + + // Get all jobs for the workflow run + const { data: { jobs } } = await github.rest.actions.listJobsForWorkflowRun({ + owner, + repo, + run_id: runId, + }); + + // Post status for each skipped job + for (const { jobName, statusContext, description } of skippedStatusJobs) { + const job = jobs.find(j => j.name === jobName); + + if (job && job.conclusion === 'skipped') { + await github.rest.repos.createCommitStatus({ + owner, + repo, + sha: headSha, + state: 'success', + context: statusContext, + description: description, + }); + console.log(`Posted skipped status for ${statusContext}`); + } else { + console.log(`Job "${jobName}" status: ${job?.conclusion ?? 'not found'} - no status posted`); + } + } diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 2e133c633a..74e6401071 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -918,30 +918,6 @@ jobs: if: env.PERCY_TOKEN == '' run: echo "::notice::Skipping Percy finalize (no token available - likely a fork PR)" - report-table-percy-skipped: - name: Report Percy Table Skipped - needs: table-visual-test - runs-on: ubuntu-latest - if: | - always() && - github.event_name == 'pull_request' && - needs.table-visual-test.result == 'skipped' - permissions: - statuses: write - steps: - - name: Post success status for percy/dash-table-test - uses: actions/github-script@v7 - with: - script: | - await github.rest.repos.createCommitStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - sha: context.payload.pull_request.head.sha, - state: 'success', - context: 'percy/dash-table-test', - description: 'Skipped — no dash-table changes', - }); - test-report: name: Consolidated Test Report needs: [lint-unit, test-main, dcc-test, html-test, table-server, background-callbacks, test-typing] From 334ce0a47f1615400cda51dd97ecd021588865d5 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 6 May 2026 17:05:34 -0400 Subject: [PATCH 5/5] fix test report for forks --- .github/workflows/post-test-status.yml | 50 ++++++++++++++++++++------ .github/workflows/testing.yml | 3 +- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/.github/workflows/post-test-status.yml b/.github/workflows/post-test-status.yml index 428295c11f..5f7ec4a3a4 100644 --- a/.github/workflows/post-test-status.yml +++ b/.github/workflows/post-test-status.yml @@ -1,10 +1,10 @@ name: Post Test Status # This workflow runs in the base repo context (with write permissions) -# even for fork PRs, allowing us to post commit statuses. +# even for fork PRs, allowing us to post commit statuses and check runs. # -# Add any status posting for skipped jobs here to avoid "Resource not -# accessible by integration" errors when PRs come from forks. +# Add any status posting or check run creation for fork PRs here to avoid +# "Resource not accessible by integration" errors. on: workflow_run: @@ -30,19 +30,13 @@ jobs: const headSha = context.payload.workflow_run.head_sha; // Define jobs that need a success status posted when skipped - // Format: { jobName: 'GitHub Job Name', statusContext: 'status/context-name', description: 'Description' } const skippedStatusJobs = [ { jobName: 'Dash Table Visual Tests', statusContext: 'percy/dash-table-test', description: 'Skipped — no dash-table changes' } - // Add more jobs here as needed: - // { - // jobName: 'Job Name', - // statusContext: 'context/name', - // description: 'Skipped — reason' - // } + // Add more jobs here as needed ]; // Get all jobs for the workflow run @@ -70,3 +64,39 @@ jobs: console.log(`Job "${jobName}" status: ${job?.conclusion ?? 'not found'} - no status posted`); } } + + test-report: + name: Consolidated Test Report (Fork PR) + runs-on: ubuntu-latest + # Only run for fork PRs (non-fork PRs are handled in the main workflow) + if: | + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.head_repository.full_name != github.repository + permissions: + checks: write + actions: read + steps: + - name: Download test results artifact + uses: actions/download-artifact@v4 + with: + pattern: '*-results-*' + path: test-results + merge-multiple: false + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + + - name: List downloaded results + run: find test-results -name "*.xml" -type f 2>/dev/null || echo "No XML files found" + + - name: Publish Test Report + uses: dorny/test-reporter@v1 + if: always() + with: + name: Test Results Summary + path: 'test-results/**/*.xml' + reporter: java-junit + fail-on-error: false + fail-on-empty: false + list-suites: 'failed' + list-tests: 'failed' + max-annotations: '50' diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 74e6401071..315499f2b0 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -939,7 +939,8 @@ jobs: - name: Publish Test Report uses: dorny/test-reporter@v1 - if: always() + # Skip for fork PRs - handled by post-test-status.yml workflow_run + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) with: name: Test Results Summary path: 'test-results/**/*.xml'