diff --git a/.github/workflows/post-test-status.yml b/.github/workflows/post-test-status.yml
new file mode 100644
index 0000000000..5f7ec4a3a4
--- /dev/null
+++ b/.github/workflows/post-test-status.yml
@@ -0,0 +1,102 @@
+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 and check runs.
+#
+# Add any status posting or check run creation for fork PRs here to avoid
+# "Resource not accessible by integration" errors.
+
+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
+ const skippedStatusJobs = [
+ {
+ jobName: 'Dash Table Visual Tests',
+ statusContext: 'percy/dash-table-test',
+ description: 'Skipped — no dash-table changes'
+ }
+ // Add more jobs here as needed
+ ];
+
+ // 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`);
+ }
+ }
+
+ 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 2e133c633a..315499f2b0 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]
@@ -963,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'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7bfcf40c7e..43afd88fe7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,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
- [#3740](https://github.com/plotly/dash/pull/3740) Fix cannot tab into dropdowns in Safari
- [#2462](https://github.com/plotly/dash/issues/2462) Allow `MATCH` in `Input`/`State` when the callback's `Output` has no wildcards (fixed-id Output, no Output, or `ALL`-only wildcard Output). `ALLSMALLER` still requires a corresponding `MATCH` in an Output.
diff --git a/dash/_callback_context.py b/dash/_callback_context.py
index 09faf6f9a3..0f4d1a9924 100644
--- a/dash/_callback_context.py
+++ b/dash/_callback_context.py
@@ -9,7 +9,6 @@
from . import exceptions
from ._utils import AttributeDict, stringify_id
-
context_value: contextvars.ContextVar[
typing.Dict[str, typing.Any]
] = contextvars.ContextVar("callback_context")
@@ -177,6 +176,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 +188,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 +200,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 340c112569..91c02e4867 100644
--- a/dash/dash.py
+++ b/dash/dash.py
@@ -663,7 +663,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()
@@ -1139,9 +1140,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)
]
)
@@ -1195,9 +1198,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]
@@ -1674,9 +1679,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
]
@@ -2636,9 +2643,11 @@ 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()
]
+ [
@@ -2708,9 +2717,11 @@ def update(pathname_, search_, **states):
]
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 771ac0180a..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
]
@@ -468,7 +470,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..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
@@ -216,7 +215,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..342ad6da6f 100644
--- a/dash/development/base_component.py
+++ b/dash/development/base_component.py
@@ -451,7 +451,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)))
+ 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..bb55328b3b 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",
@@ -111,7 +110,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 6287e2af2e..bbe92dceb6 100644
--- a/dash/testing/browser.py
+++ b/dash/testing/browser.py
@@ -32,7 +32,6 @@
from dash.testing.errors import DashAppLoadingError, BrowserError, TestingTimeoutError
from dash.testing.consts import SELENIUM_GRID_DEFAULT
-
logger = logging.getLogger(__name__)
@@ -629,7 +628,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")
+ warnings.warn(
+ "get_logs always return None with webdrivers other than Chrome",
+ stacklevel=2,
+ )
return None
def reset_log_timestamp(self):