Compare commits

..

1 Commits

Author SHA1 Message Date
rusackas
2f0e3bce25 feat(ci): publish Python unit test results as PR check annotations
Right now a red check on the Python-Unit job just says "failed" and
sends contributors to raw pytest logs to find out which test broke.
Add EnricoMi/publish-unit-test-result-action so failing tests get
annotated inline on the PR diff instead.

pytest already writes JUnit XML for free via --junit-xml; wire that up
for all three pytest invocations in the unit-tests job and upload it as
an artifact (uploaded even on failure, since that's exactly when it's
needed). A second workflow, triggered via workflow_run (same pattern
already used for the translation-regression bot), downloads that
artifact and publishes the check run. workflow_run always runs in the
base-branch context, so it can safely be granted checks:write even for
PRs from forks or Dependabot, without ever checking out untrusted PR
code.

Scoped to Python-Unit only for now; the frontend's 8-way jest shard and
the Python integration test matrix are natural follow-ups once this
pattern proves out.
2026-07-27 22:00:00 -07:00
13 changed files with 115 additions and 323 deletions

View File

@@ -0,0 +1,65 @@
name: Python Unit Test Results
on:
# zizmor: ignore[dangerous-triggers] - runs in base-branch context and only consumes artifacts uploaded by Python-Unit; never checks out PR code (see note below)
workflow_run:
workflows: ["Python-Unit"]
types: [completed]
# This workflow publishes a check run annotating failing Python unit tests
# inline on the PR diff, using JUnit XML uploaded by the Python-Unit workflow.
# It uses the workflow_run trigger so that it always runs in the base-branch
# context and can safely be granted write permissions, even for PRs from
# forks or Dependabot.
#
# IMPORTANT: This workflow must NEVER check out code from the PR branch. All
# data comes from artifacts uploaded by the Python-Unit workflow.
permissions:
contents: read
checks: write
issues: read
actions: read
jobs:
report:
runs-on: ubuntu-26.04
timeout-minutes: 10
if: >
github.event.workflow_run.conclusion == 'success' ||
github.event.workflow_run.conclusion == 'failure'
steps:
# Fails soft (continue-on-error) because the source unit-tests job is
# itself gated on change detection: a docs-only PR skips it entirely,
# so there is nothing to download or report on.
- name: Download JUnit results
id: download
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: "junit-results-*"
path: artifacts
merge-multiple: true
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Download event file
id: download-event
if: steps.download.outcome == 'success'
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: "Event File"
path: event
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish test results
if: steps.download.outcome == 'success' && steps.download-event.outcome == 'success'
uses: EnricoMi/publish-unit-test-result-action@d0a4676d0e0b938bc201470d88276b7c74c712b3 # v2.24.0
with:
commit: ${{ github.event.workflow_run.head_sha }}
event_file: event/event.json
event_name: ${{ github.event.workflow_run.event }}
files: "artifacts/**/*.xml"
check_name: "Python Unit Test Results"
comment_mode: "off"

View File

@@ -74,14 +74,14 @@ jobs:
SUPERSET_TESTENV: true
SUPERSET_SECRET_KEY: not-a-secret
run: |
pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear --maxfail=50
pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear --maxfail=50 --junit-xml=test-results/junit-unit.xml
- name: Python 100% coverage unit tests
env:
SUPERSET_TESTENV: true
SUPERSET_SECRET_KEY: not-a-secret
run: |
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-sql-coverage.xml
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-semantic-layers-coverage.xml
- name: Upload code coverage
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
@@ -89,6 +89,33 @@ jobs:
verbose: true
use_oidc: true
slug: apache/superset
# Uploaded even when a pytest step above fails, since that is exactly
# when the JUnit results are needed downstream, to annotate the PR with
# the failing tests. Consumed by the "Python Unit Test Results" workflow
# via workflow_run (see that workflow for why it can't just be a step
# here: it needs to run with write permissions, which this PR-triggered
# job can't safely have on a fork PR).
- name: Upload JUnit test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: junit-results-${{ matrix.python-version }}
path: test-results/
retention-days: 7
# Uploads the raw pull_request event payload so the "Python Unit Test
# Results" workflow (running via workflow_run, in base-branch context) can
# look up which PR/commit to annotate without checking out untrusted code.
event-file:
runs-on: ubuntu-26.04
timeout-minutes: 5
steps:
- name: Upload event file
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: Event File
path: ${{ github.event_path }}
retention-days: 7
# Stable required-status-check anchor. `unit-tests` is a matrix job gated on
# change detection, so on non-Python PRs it is skipped and never produces its

View File

@@ -17,7 +17,6 @@
* under the License.
*/
import {
fireEvent,
render,
screen,
userEvent,
@@ -42,30 +41,6 @@ jest.mock('@superset-ui/core', () => ({
})),
}));
// Mock the Advanced JSON editor (Ace-based, not drivable in jsdom) with a plain
// textarea so a direct JSON edit can be simulated. Keeps the "JSON Metadata"
// label so the existing "should open advance" test still passes.
jest.mock('./sections/AdvancedSection', () => ({
__esModule: true,
default: ({
jsonMetadata,
onJsonMetadataChange,
}: {
jsonMetadata: string;
onJsonMetadataChange: (value: string) => void;
}) => (
<div>
<span>JSON Metadata</span>
<textarea
aria-label="JSON metadata editor"
data-test="mock-json-editor"
value={jsonMetadata}
onChange={e => onJsonMetadataChange(e.target.value)}
/>
</div>
),
}));
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
const spyColorSchemeSelect = jest.spyOn(ColorSchemeSelect, 'default');
@@ -284,56 +259,6 @@ describe('PropertiesModal', () => {
});
});
test('preserves a refresh_frequency edited in the JSON editor on save (#42116)', async () => {
// Save (onlyApply: false) PUTs to the API before calling onSubmit, so the
// request must be mocked or onSubmit is never reached.
const put = jest.spyOn(SupersetCore.SupersetClient, 'put');
put.mockResolvedValue({
json: {
result: {
dashboard_title: 'dashboard_title',
slug: 'slug',
json_metadata: 'json_metadata',
editors: 'editors',
},
},
} as any);
mockedIsFeatureEnabled.mockReturnValue(false);
const props = createProps();
const propsWithDashboardInfo = {
...props,
dashboardInfo: {
...dashboardInfo,
json_metadata: mockedJsonMetadata,
},
};
render(<PropertiesModal {...propsWithDashboardInfo} />, {
useRedux: true,
});
await screen.findByTestId('dashboard-edit-properties-form');
// Expand the Advanced settings panel so the (mocked) JSON editor mounts.
const advancedHeader = screen
.getByText('Advanced settings')
.closest('.ant-collapse-header');
await userEvent.click(advancedHeader!);
// Edit refresh_frequency directly in the JSON editor without touching the
// Refresh dropdown (the reproduction of #42116).
const editor = await screen.findByTestId('mock-json-editor');
fireEvent.change(editor, {
target: { value: JSON.stringify({ refresh_frequency: 30 }) },
});
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledTimes(1);
});
const submitted = JSON.parse(props.onSubmit.mock.calls[0][0].jsonMetadata);
expect(submitted.refresh_frequency).toBe(30);
});
test('should close modal', async () => {
mockedIsFeatureEnabled.mockImplementation((flag: any) => {
if (flag === FeatureFlag.TaggingSystem) return true;

View File

@@ -238,19 +238,17 @@ const PropertiesModal = ({
}, handleErrorResponse);
}, [dashboardId, handleDashboardData]);
// Returns undefined (rather than {}) when the editor holds invalid JSON,
// so callers can distinguish "empty/no metadata yet" from "user is
// mid-edit with unparsable text" and avoid clobbering the latter.
const parseJsonMetadata = () => {
const getJsonMetadata = () => {
try {
return jsonMetadata?.length ? JSON.parse(jsonMetadata) : {};
const jsonMetadataObj = jsonMetadata?.length
? JSON.parse(jsonMetadata)
: {};
return jsonMetadataObj;
} catch (_) {
return undefined;
return {};
}
};
const getJsonMetadata = () => parseJsonMetadata() ?? {};
const handleOnChangeEditors = (values: SubjectPickerValue[]) => {
setEditors(mapPickerValuesToSubjects(values));
};
@@ -345,12 +343,7 @@ const PropertiesModal = ({
? resettableCustomLabels
: false;
const jsonMetadataObj = getJsonMetadata();
// A refresh_frequency edited directly in the Advanced JSON editor takes
// precedence over the Refresh dropdown state, mirroring how color_scheme is
// handled above. Nullish coalescing preserves an explicit 0 ("Don't
// refresh") rather than falling through to the dropdown value (#42116).
jsonMetadataObj.refresh_frequency =
jsonMetadataObj.refresh_frequency ?? refreshFrequency;
jsonMetadataObj.refresh_frequency = refreshFrequency;
jsonMetadataObj.show_chart_timestamps = Boolean(showChartTimestamps);
const customLabelColors = jsonMetadataObj.label_colors || {};
const updatedDashboardMetadata = {
@@ -544,19 +537,8 @@ const PropertiesModal = ({
// Section handlers for extracted components
const handleThemeChange = (value: any) => setSelectedThemeId(value || null);
const handleRefreshFrequencyChange = (value: number) => {
const handleRefreshFrequencyChange = (value: number) =>
setRefreshFrequency(value);
// Keep the Advanced JSON editor in sync with the dropdown so the two
// sources can't diverge, mirroring onColorSchemeChange (#42116). Skip
// this while the editor holds invalid/in-progress JSON so we don't
// clobber the user's unfinished edit with a one-field object.
const jsonMetadataObj = parseJsonMetadata();
if (!jsonMetadataObj) {
return;
}
jsonMetadataObj.refresh_frequency = value;
setJsonMetadata(jsonStringify(jsonMetadataObj));
};
// Helper function for styling section
const hasCustomLabelsColor = !!Object.keys(
@@ -598,15 +580,7 @@ const PropertiesModal = ({
const refreshLimit =
dashboardInfo?.common?.conf
?.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT;
// A refresh_frequency edited directly in the Advanced JSON editor
// takes precedence over the dropdown when saving (see onFinish),
// so validate that effective value rather than the dropdown state.
const effectiveRefreshFrequency =
getJsonMetadata()?.refresh_frequency ?? refreshFrequency;
return validateRefreshFrequency(
effectiveRefreshFrequency,
refreshLimit,
);
return validateRefreshFrequency(refreshFrequency, refreshLimit);
},
},
{

View File

@@ -318,7 +318,7 @@ class QueryContextFactory: # pylint: disable=too-few-public-methods
# another temporal filter. A new filter based on the value of
# the granularity will be added later in the code.
# In practice, this is replacing the previous default temporal filter.
if filter_to_remove and is_adhoc_column(filter_to_remove): # type: ignore
if is_adhoc_column(filter_to_remove): # type: ignore
filter_to_remove = filter_to_remove.get("sqlExpression")
if filter_to_remove:

View File

@@ -206,9 +206,11 @@ class QueryCacheManager:
)
query_cache.status = QueryStatus.SUCCESS
query_cache.is_loaded = True
query_cache.is_cached = True
query_cache.is_cached = cache_value is not None
query_cache.sql_rowcount = cache_value.get("sql_rowcount", None)
query_cache.cache_dttm = cache_value["dttm"]
query_cache.cache_dttm = (
cache_value["dttm"] if cache_value is not None else None
)
query_cache.queried_dttm = cache_value.get(
"queried_dttm", cache_value.get("dttm")
)

View File

@@ -396,36 +396,14 @@ class DashboardDAO(BaseDAO[Dashboard]):
else:
md["color_namespace"] = data.get("color_namespace")
# Only overwrite these metadata fields when the caller explicitly sends
# them. Previously each used ``data.get(key, default)``, which reset a
# value to its default whenever it was absent from the payload -- e.g. a
# ``refresh_frequency`` set directly in the Advanced JSON editor got
# wiped on save. ``setdefault`` still seeds a default for brand-new
# dashboards that have never had the key, keeping the shape stable
# without clobbering existing values (#42116).
metadata_defaults: dict[str, Any] = {
"expanded_slices": {},
"refresh_frequency": 0,
"color_scheme": "",
"label_colors": {},
"cross_filters_enabled": True,
}
for key, default_value in metadata_defaults.items():
if key in data:
md[key] = data[key]
else:
md.setdefault(key, default_value)
# These are derived from ``color_scheme``/``label_colors`` on the
# frontend (see ``applyDashboardLabelsColorOnLoad``) and are always
# omitted from the Properties modal's payload, so they must keep
# resetting to their default when absent rather than being preserved
# -- otherwise a color scheme change made through that modal leaves
# stale label-to-color mappings in place instead of triggering
# recomputation on next load.
md["expanded_slices"] = data.get("expanded_slices", {})
md["refresh_frequency"] = data.get("refresh_frequency", 0)
md["color_scheme"] = data.get("color_scheme", "")
md["label_colors"] = data.get("label_colors", {})
md["shared_label_colors"] = data.get("shared_label_colors", [])
md["map_label_colors"] = data.get("map_label_colors", {})
md["color_scheme_domain"] = data.get("color_scheme_domain", [])
md["cross_filters_enabled"] = data.get("cross_filters_enabled", True)
dashboard.json_metadata = json.dumps(md)
@staticmethod

View File

@@ -534,7 +534,6 @@ class DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngine
],
) -> list[SupersetError]:
errors: list[SupersetError] = []
connect_args: dict[str, Any] = {}
if extra := json.loads(properties.get("extra")): # type: ignore
engine_params = extra.get("engine_params", {})
connect_args = engine_params.get("connect_args", {})

View File

@@ -1235,7 +1235,6 @@ class PrestoEngineSpec(PrestoBaseEngineSpec):
all_columns: list[ResultSetColumnType] = []
expanded_columns = []
current_array_level = None
unnested_rows: dict[int, int] = defaultdict(int)
while to_process:
column, level = to_process.popleft()
if column["column_name"] not in [
@@ -1249,7 +1248,7 @@ class PrestoEngineSpec(PrestoBaseEngineSpec):
# added by the first. every time we change a level in the nested arrays
# we reinitialize this.
if level != current_array_level:
unnested_rows = defaultdict(int)
unnested_rows: dict[int, int] = defaultdict(int)
current_array_level = level
name = column["column_name"]

View File

@@ -73,46 +73,6 @@ class TestDashboardDAO(SupersetTestCase):
DashboardDAO.set_dash_metadata(dashboard, original_data)
db.session.commit()
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
@patch("superset.utils.core.g")
@patch("superset.security.manager.g")
def test_set_dash_metadata_preserves_unsent_fields(self, mock_sm_g, mock_g):
"""
set_dash_metadata must not reset metadata fields that are absent from the
incoming payload, such as a ``refresh_frequency`` edited directly in the
Advanced JSON editor (#42116). Fields that are present still override.
"""
mock_g.user = mock_sm_g.user = security_manager.find_user("admin")
with self.client.application.test_request_context():
dashboard = (
db.session.query(Dashboard).filter_by(slug="world_health").first()
)
original_json_metadata = dashboard.json_metadata
try:
# Seed an existing refresh_frequency in the stored metadata.
metadata = json.loads(dashboard.json_metadata or "{}")
metadata["refresh_frequency"] = 60
dashboard.json_metadata = json.dumps(metadata)
db.session.commit()
# Payload omits refresh_frequency: it must be preserved, not
# reset to 0.
DashboardDAO.set_dash_metadata(
dashboard, {"color_scheme": "d3Category10"}
)
db.session.commit()
saved = json.loads(dashboard.json_metadata)
assert saved["refresh_frequency"] == 60
assert saved["color_scheme"] == "d3Category10"
# An explicitly-sent value still overrides.
DashboardDAO.set_dash_metadata(dashboard, {"refresh_frequency": 30})
db.session.commit()
assert json.loads(dashboard.json_metadata)["refresh_frequency"] == 30
finally:
dashboard.json_metadata = original_json_metadata
db.session.commit()
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
@patch("superset.daos.dashboard.g")
@patch("superset.security.manager.g")

View File

@@ -532,42 +532,6 @@ class TestQueryContextFactory:
assert query_object.columns == ["ds", "other_col"]
def test_apply_granularity_no_filter_to_remove(self):
"""No x-axis and no temporal filters leaves the filters untouched."""
query_object = Mock(spec=QueryObject)
query_object.granularity = "P1D"
query_object.columns = ["other_col"]
query_object.post_processing = []
query_object.filter = [{"col": "other_col", "op": "==", "val": "value"}]
datasource = Mock()
datasource.columns = [{"column_name": "ds", "is_dttm": True}]
self.factory._apply_granularity(query_object, {}, datasource)
assert query_object.filter == [{"col": "other_col", "op": "==", "val": "value"}]
def test_apply_granularity_with_adhoc_temporal_filter(self):
"""An adhoc temporal filter is matched on its SQL expression."""
adhoc_column = {"label": "ds_expr", "sqlExpression": "DATE(ds)"}
query_object = Mock(spec=QueryObject)
query_object.granularity = "P1D"
query_object.columns = ["other_col"]
query_object.post_processing = []
query_object.filter = [
{"col": adhoc_column, "op": "TEMPORAL_RANGE", "val": "a : b"},
{"col": "DATE(ds)", "op": "TEMPORAL_RANGE", "val": "a : b"},
]
datasource = Mock()
datasource.columns = [{"column_name": "ds", "is_dttm": True}]
self.factory._apply_granularity(query_object, {}, datasource)
assert query_object.filter == [
{"col": adhoc_column, "op": "TEMPORAL_RANGE", "val": "a : b"}
]
def test_apply_filters_with_time_range(self):
"""Test _apply_filters with time_range"""
query_object = Mock(spec=QueryObject)

View File

@@ -24,7 +24,6 @@ from superset.connectors.sqla.models import Database, SqlaTable
from superset.daos.dashboard import DashboardDAO
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.utils import json
from tests.unit_tests.conftest import with_feature_flags
@@ -118,53 +117,3 @@ def test_set_dash_metadata_preserves_soft_deleted_members(
)
# And the position slot kept its UUID rather than being nulled.
assert positions["CHART-trashed"]["meta"]["uuid"] == str(trashed_chart.uuid)
def test_set_dash_metadata_preserves_refresh_frequency(session: Session) -> None:
"""set_dash_metadata must not reset refresh_frequency when absent from data.
Regression test for #42116: ``data.get("refresh_frequency", 0)`` would
unconditionally overwrite the existing value with 0 whenever the caller
did not include ``refresh_frequency`` in the data dict.
"""
Dashboard.metadata.create_all(session.get_bind())
dashboard = Dashboard(
dashboard_title="refresh_test_dash",
json_metadata=json.dumps({"refresh_frequency": 30}),
)
db.session.add(dashboard)
db.session.flush()
# Simulate a save that does NOT include refresh_frequency
# (e.g. changing only the title via the PropertiesModal).
DashboardDAO.set_dash_metadata(dashboard, {"color_scheme": "superset"})
md = json.loads(dashboard.json_metadata)
assert md["refresh_frequency"] == 30, (
"refresh_frequency should be preserved when not present in data"
)
def test_set_dash_metadata_updates_refresh_frequency_when_present(
session: Session,
) -> None:
"""set_dash_metadata must update refresh_frequency when it IS in data."""
Dashboard.metadata.create_all(session.get_bind())
dashboard = Dashboard(
dashboard_title="refresh_test_dash_2",
json_metadata=json.dumps({"refresh_frequency": 30}),
)
db.session.add(dashboard)
db.session.flush()
# Simulate a save that explicitly sets refresh_frequency to 0.
DashboardDAO.set_dash_metadata(
dashboard, {"refresh_frequency": 0, "color_scheme": "superset"}
)
md = json.loads(dashboard.json_metadata)
assert md["refresh_frequency"] == 0, (
"refresh_frequency should be updated when present in data"
)

View File

@@ -571,53 +571,3 @@ def test_stringify_values_non_serializable_dict_falls_back_to_str() -> None:
# Must not raise — falls back to str()
result = stringify_values(data)
assert result[0] == str({"key": _Unserializable()})
def test_empty_result_set_preserves_column_metadata() -> None:
"""
Test that column metadata is preserved when query returns zero rows.
When a query returns no data but has a valid cursor description, the
column names and types from cursor_description should be preserved
in the result set. This allows downstream consumers (like the UI)
to display column headers even for empty result sets.
"""
data: DbapiResult = []
description = [
("id", "int", None, None, None, None, True),
("name", "varchar", None, None, None, None, True),
("created_at", "timestamp", None, None, None, None, True),
]
result_set = SupersetResultSet(
data,
description, # type: ignore
BaseEngineSpec,
)
# Verify column count
assert len(result_set.columns) == 3
# Verify column names are preserved
column_names = [col["column_name"] for col in result_set.columns]
assert column_names == ["id", "name", "created_at"]
assert result_set.columns[0]["type"] == BaseEngineSpec.get_datatype(
description[0][1]
)
assert result_set.columns[1]["type"] == BaseEngineSpec.get_datatype(
description[1][1]
)
assert result_set.columns[2]["type"] == BaseEngineSpec.get_datatype(
description[2][1]
)
# Verify the PyArrow table has the correct schema
assert result_set.table.num_rows == 0
assert len(result_set.table.column_names) == 3
assert list(result_set.table.column_names) == ["id", "name", "created_at"]
# Verify DataFrame conversion works
df = result_set.to_pandas_df()
assert len(df) == 0
assert list(map(str, df.columns)) == ["id", "name", "created_at"]