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):