Skip to content

Commit f9cedf8

Browse files
AryaKetanShCtclauderusackas
authored
fix: drop post-processing options the operation no longer accepts (#42927)
Signed-off-by: Arya Ketan <aryaketan@sharechat.co> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Evan Rusackas <evan@preset.io>
1 parent c2d653b commit f9cedf8

4 files changed

Lines changed: 250 additions & 4 deletions

File tree

superset/common/query_object.py

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
# pylint: disable=invalid-name
1818
from __future__ import annotations
1919

20+
import inspect
2021
import logging
2122
from datetime import datetime
2223
from pprint import pformat
@@ -205,8 +206,86 @@ def is_str_or_adhoc(metric: Metric) -> bool:
205206
def _set_post_processing(
206207
self, post_processing: list[dict[str, Any] | None] | None
207208
) -> None:
208-
post_processing = post_processing or []
209-
self.post_processing = [post_proc for post_proc in post_processing if post_proc]
209+
self.post_processing = [
210+
self._drop_unsupported_options(post_proc)
211+
for post_proc in post_processing or []
212+
if post_proc
213+
]
214+
215+
@staticmethod
216+
def _drop_unsupported_options(post_proc: dict[str, Any]) -> dict[str, Any]:
217+
"""
218+
Drop options that the post-processing operation no longer accepts.
219+
220+
A chart's ``query_context`` is written when the chart is saved and is
221+
never rewritten afterwards, while Explore rebuilds the query from
222+
``form_data`` at every render. A chart saved by an older version of
223+
Superset can therefore reference an option that has since been removed
224+
from the operation. ``exec_post_processing`` passes the stored options
225+
as keyword arguments, so that option raises a bare ``TypeError`` on
226+
every path that replays the stored ``query_context`` -- the chart data
227+
endpoint, alerts and reports, thumbnails, CSV export -- while the same
228+
chart still renders correctly in Explore.
229+
230+
Comparing against the signature avoids a hard-coded list of removed
231+
option names, which would need extending at each release.
232+
"""
233+
operation = post_proc.get("operation")
234+
function = (
235+
getattr(pandas_postprocessing, operation, None)
236+
if isinstance(operation, str)
237+
else None
238+
)
239+
if function is None:
240+
# A missing or unknown operation is left untouched, so that
241+
# exec_post_processing reports it as InvalidPostProcessingError.
242+
return post_proc
243+
244+
parameters = inspect.signature(function).parameters
245+
if any(
246+
parameter.kind is inspect.Parameter.VAR_KEYWORD
247+
for parameter in parameters.values()
248+
):
249+
return post_proc
250+
251+
# `exec_post_processing` calls the operation as `operation(df, **options)`,
252+
# so an option can only reach a parameter that a caller may fill by
253+
# keyword. That excludes the first parameter, which receives the
254+
# DataFrame positionally, and any positional-only or `*args` parameter.
255+
keyword_parameters = {
256+
name
257+
for position, (name, parameter) in enumerate(parameters.items())
258+
if position > 0
259+
and parameter.kind
260+
in (
261+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
262+
inspect.Parameter.KEYWORD_ONLY,
263+
)
264+
}
265+
266+
options = post_proc.get("options") or {}
267+
unsupported = {key for key in options if key not in keyword_parameters}
268+
if not unsupported:
269+
return post_proc
270+
271+
# Logged at info: a chart saved before the option was removed hits this
272+
# on every render, so a warning would repeat for as long as the chart
273+
# is not resaved, without anything new to report.
274+
logger.info(
275+
"Dropping unsupported option(s) %s of post-processing operation "
276+
"`%s`. The chart's stored query_context predates the current "
277+
"signature of that operation.",
278+
sorted(unsupported),
279+
operation,
280+
)
281+
return {
282+
**post_proc,
283+
"options": {
284+
key: value
285+
for key, value in options.items()
286+
if key in keyword_parameters
287+
},
288+
}
210289

211290
def _init_series_columns(
212291
self,

superset/utils/pandas_postprocessing/utils.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# specific language governing permissions and limitations
1616
# under the License.
1717
from collections.abc import Sequence
18-
from functools import partial
18+
from functools import partial, wraps
1919
from typing import Any, Callable
2020

2121
import numpy as np
@@ -122,6 +122,10 @@ def scalar_to_sequence(val: Any) -> Sequence[str]:
122122

123123
def validate_column_args(*argnames: str) -> Callable[..., Any]:
124124
def wrapper(func: Callable[..., Any]) -> Callable[..., Any]:
125+
# `wraps` keeps `func` reachable through `__wrapped__`, so that
126+
# `inspect.signature` reports the parameters of the decorated operation
127+
# rather than the `(df, **options)` of this wrapper.
128+
@wraps(func)
125129
def wrapped(df: DataFrame, **options: Any) -> Any:
126130
if _is_multi_index_on_columns(df):
127131
# MultiIndex column validate first level

tests/unit_tests/pandas_postprocessing/test_utils.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@
1414
# KIND, either express or implied. See the License for the
1515
# specific language governing permissions and limitations
1616
# under the License.
17-
from superset.utils.pandas_postprocessing import escape_separator, unescape_separator
17+
import inspect
18+
19+
from superset.utils.pandas_postprocessing import (
20+
escape_separator,
21+
pivot,
22+
unescape_separator,
23+
)
1824

1925

2026
def test_escape_separator():
@@ -28,3 +34,19 @@ def test_escape_separator():
2834
escape_string = escape_separator("hello,world")
2935
assert escape_string == r"hello\,world"
3036
assert unescape_separator(escape_string) == "hello,world"
37+
38+
39+
def test_validate_column_args_preserves_signature():
40+
"""
41+
The decorator must not hide the signature of the operation it wraps.
42+
43+
`inspect.signature` follows `__wrapped__`, which `functools.wraps` sets.
44+
Without it every decorated operation reports `(df, **options)`, and code
45+
that inspects the signature -- see `QueryObject._drop_unsupported_options`
46+
-- cannot tell a supported option from an unsupported one.
47+
"""
48+
parameters = inspect.signature(pivot).parameters
49+
50+
assert pivot.__name__ == "pivot"
51+
assert "options" not in parameters
52+
assert {"index", "aggregates", "columns"} <= set(parameters)

tests/unit_tests/queries/query_object_test.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from superset.connectors.sqla.models import SqlaTable
2323
from superset.models.core import Database
2424
from superset.superset_typing import Metric
25+
from superset.utils import pandas_postprocessing
2526
from superset.utils.core import override_user
2627

2728

@@ -438,3 +439,143 @@ def test_cache_key_cache_impersonation_on_with_different_user_and_db_impersonati
438439
],
439440
any_order=True,
440441
)
442+
443+
444+
def test_post_processing_drops_unsupported_options():
445+
"""
446+
An option that the operation no longer accepts is dropped, not passed on.
447+
448+
A chart saved by an older version of Superset stores `flatten_columns` in
449+
the options of its `pivot` operation. `pivot` lost that parameter when
450+
flattening became its own operation, so replaying the stored query_context
451+
raised `TypeError: pivot() got an unexpected keyword argument
452+
'flatten_columns'`.
453+
"""
454+
query_object = QueryObject(
455+
row_limit=1,
456+
post_processing=[
457+
{
458+
"operation": "pivot",
459+
"options": {
460+
"index": ["__timestamp"],
461+
"columns": ["genre"],
462+
"aggregates": {"count": {"operator": "mean"}},
463+
"drop_missing_columns": False,
464+
"flatten_columns": True,
465+
"reset_index": True,
466+
},
467+
}
468+
],
469+
)
470+
471+
options = query_object.post_processing[0]["options"]
472+
assert "flatten_columns" not in options
473+
assert "reset_index" not in options
474+
assert options["drop_missing_columns"] is False
475+
assert options["index"] == ["__timestamp"]
476+
477+
478+
def test_post_processing_keeps_supported_options():
479+
"""Options the operation accepts are left alone."""
480+
post_processing = [
481+
{
482+
"operation": "pivot",
483+
"options": {"index": ["__timestamp"], "aggregates": {}},
484+
}
485+
]
486+
query_object = QueryObject(row_limit=1, post_processing=post_processing)
487+
488+
assert query_object.post_processing == post_processing
489+
490+
491+
def test_post_processing_keeps_unknown_operation():
492+
"""
493+
An unknown operation is kept, so that `exec_post_processing` can report it
494+
as an `InvalidPostProcessingError` rather than being silently dropped here.
495+
"""
496+
query_object = QueryObject(
497+
row_limit=1,
498+
post_processing=[{"operation": "does_not_exist", "options": {"a": 1}}, None],
499+
)
500+
501+
assert query_object.post_processing == [
502+
{"operation": "does_not_exist", "options": {"a": 1}}
503+
]
504+
505+
506+
def test_post_processing_drops_the_dataframe_parameter():
507+
"""
508+
The DataFrame parameter is not an option.
509+
510+
`exec_post_processing` calls `operation(df, **options)`, so an option named
511+
after the first parameter would raise `TypeError: pivot() got multiple
512+
values for argument 'df'`.
513+
"""
514+
query_object = QueryObject(
515+
row_limit=1,
516+
post_processing=[
517+
{
518+
"operation": "pivot",
519+
"options": {"df": "malformed", "index": ["a"], "aggregates": {}},
520+
}
521+
],
522+
)
523+
524+
options = query_object.post_processing[0]["options"]
525+
assert "df" not in options
526+
assert options["index"] == ["a"]
527+
528+
529+
def test_post_processing_keeps_options_of_a_variadic_operation():
530+
"""An operation that accepts `**kwargs` accepts every option."""
531+
532+
def variadic(df, **kwargs):
533+
return df
534+
535+
post_processing = [{"operation": "variadic", "options": {"anything": 1}}]
536+
with patch.object(pandas_postprocessing, "variadic", variadic, create=True):
537+
query_object = QueryObject(row_limit=1, post_processing=post_processing)
538+
539+
assert query_object.post_processing == post_processing
540+
541+
542+
def test_post_processing_drops_a_variadic_positional_option():
543+
"""
544+
A `*args` parameter cannot be filled by a keyword argument.
545+
546+
`exec_post_processing` calls the operation as `operation(df, **options)`,
547+
so an option named after a `*args` parameter would raise `TypeError:
548+
variadic_positional() got an unexpected keyword argument 'args'` even
549+
though the name appears in the signature.
550+
"""
551+
552+
def variadic_positional(df, *args, index=None): # pylint: disable=unused-argument
553+
return df
554+
555+
with patch.object(
556+
pandas_postprocessing, "variadic_positional", variadic_positional, create=True
557+
):
558+
query_object = QueryObject(
559+
row_limit=1,
560+
post_processing=[
561+
{
562+
"operation": "variadic_positional",
563+
"options": {"args": [1], "index": ["a"]},
564+
}
565+
],
566+
)
567+
568+
options = query_object.post_processing[0]["options"]
569+
assert "args" not in options
570+
assert options["index"] == ["a"]
571+
572+
573+
def test_post_processing_keeps_an_entry_without_an_operation():
574+
"""
575+
An entry that names no operation is kept, so that `exec_post_processing`
576+
reports it as an `InvalidPostProcessingError`.
577+
"""
578+
post_processing = [{"options": {"a": 1}}]
579+
query_object = QueryObject(row_limit=1, post_processing=post_processing)
580+
581+
assert query_object.post_processing == post_processing

0 commit comments

Comments
 (0)