@@ -146,6 +146,54 @@ def _estimate_group_count(df: Any, fields: list[str]) -> int:
146146 return min (len (df ), int (prod (distinct_counts )))
147147
148148
149+ def _match_source_filter_values (series : Any , values : list [Any ]) -> Any :
150+ """Match raw source-filter values with explicit null handling.
151+
152+ ``None`` in the filter list matches null-like pandas values via ``isna()``.
153+ Other values use raw ``isin()`` comparison with no type coercion.
154+ """
155+ has_null = any (v is None for v in values )
156+ non_null = [v for v in values if v is not None ]
157+ mask = pd .Series (False , index = series .index )
158+ if non_null :
159+ mask |= series .isin (non_null )
160+ if has_null :
161+ mask |= series .isna ()
162+ return mask
163+
164+
165+ def _apply_source_filters (
166+ df : Any ,
167+ source_filters : dict [str , dict [str , list [Any ]]] | None ,
168+ ) -> Any :
169+ """Apply server-only raw-value filters to the source DataFrame.
170+
171+ Semantics intentionally differ from ``_resolve_and_filter``:
172+ - raw Python values, not resolved frontend keys
173+ - ``None`` matches null-like pandas values
174+ - ``""`` matches only literal empty strings
175+ - no type coercion is performed
176+ - include takes precedence over exclude
177+ """
178+ if not source_filters :
179+ return df
180+ mask = pd .Series (True , index = df .index )
181+ for field , filt in source_filters .items ():
182+ if field not in df .columns :
183+ raise ValueError (
184+ f"source_filters contains column not in DataFrame: { field !r} . "
185+ f"Available columns: { sorted (df .columns .tolist ())} "
186+ )
187+ col = df [field ]
188+ include = filt .get ("include" )
189+ exclude = filt .get ("exclude" )
190+ if include :
191+ mask &= _match_source_filter_values (col , include )
192+ elif exclude :
193+ mask &= ~ _match_source_filter_values (col , exclude )
194+ return df [mask ]
195+
196+
149197def _can_use_threshold_hybrid (config : PivotConfig ) -> tuple [bool , str ]:
150198 if config .get ("synthetic_measures" ):
151199 return False , "threshold_hybrid currently skips synthetic measures"
@@ -998,6 +1046,8 @@ def st_pivot_table(
9981046 enable_drilldown : bool = True ,
9991047 export_filename : str | None = None ,
10001048 execution_mode : str = "auto" ,
1049+ # Report-level filtering
1050+ source_filters : dict [str , dict [str , list [Any ]]] | None = None ,
10011051) -> PivotTableResult :
10021052 """Create a pivot table component.
10031053
@@ -1056,6 +1106,13 @@ def st_pivot_table(
10561106 ``st.session_state[key]`` after the callback fires.
10571107 If None, a no-op is supplied at mount to satisfy the CCv2 contract
10581108 (every ``default={}`` key needs a matching ``on_<key>_change``).
1109+ source_filters : dict[str, dict[str, list[Any]]] or None
1110+ Server-only report-level filters applied to the source DataFrame
1111+ before any pivot processing. Unlike interactive ``config.filters``,
1112+ these filters are not sent to the frontend and are not tied to the
1113+ current row/column layout. ``include`` takes precedence over
1114+ ``exclude``. ``None`` matches null-like values, while ``""`` matches
1115+ only literal empty strings. No type coercion is performed.
10591116 null_handling : str or dict[str, str] or None
10601117 How to treat null/NaN values. Global mode ("exclude", "zero",
10611118 "separate") or per-field dict mapping column names to modes.
@@ -1261,6 +1318,45 @@ def st_pivot_table(
12611318
12621319 # --- Column list type + membership validation ---
12631320 df_cols = set (data .columns )
1321+
1322+ if source_filters is not None :
1323+ if not isinstance (source_filters , dict ):
1324+ raise TypeError (
1325+ f"source_filters must be a dict or None, got { type (source_filters ).__name__ } "
1326+ )
1327+ if not source_filters :
1328+ source_filters = None
1329+ else :
1330+ for field , filt in source_filters .items ():
1331+ if not isinstance (field , str ):
1332+ raise TypeError ("source_filters keys must be strings" )
1333+ if field not in df_cols :
1334+ raise ValueError (
1335+ f"source_filters contains column not in DataFrame: { field !r} . "
1336+ f"Available columns: { sorted (df_cols )} "
1337+ )
1338+ if not isinstance (filt , dict ):
1339+ raise TypeError (f"source_filters[{ field !r} ] must be a dict" )
1340+ extra_keys = [k for k in filt if k not in {"include" , "exclude" }]
1341+ if extra_keys :
1342+ raise ValueError (
1343+ f"source_filters[{ field !r} ] contains unsupported keys: { extra_keys } . "
1344+ "Only 'include' and 'exclude' are allowed."
1345+ )
1346+ for op_name in ("include" , "exclude" ):
1347+ vals = filt .get (op_name )
1348+ if vals is None :
1349+ continue
1350+ if not isinstance (vals , list ):
1351+ raise TypeError (
1352+ f"source_filters[{ field !r} ]['{ op_name } '] must be a list"
1353+ )
1354+ for idx , value in enumerate (vals ):
1355+ if not pd .api .types .is_scalar (value ):
1356+ raise TypeError (
1357+ f"source_filters[{ field !r} ]['{ op_name } '][{ idx } ] must be a scalar value"
1358+ )
1359+
12641360 for param_name , col_list in [
12651361 ("rows" , rows ),
12661362 ("columns" , columns ),
@@ -1280,18 +1376,22 @@ def st_pivot_table(
12801376 f"Available columns: { sorted (df_cols )} "
12811377 )
12821378
1379+ filtered_data = _apply_source_filters (data , source_filters )
1380+
12831381 # --- Auto-detect dimensions/measures when not specified ---
12841382 resolved_rows = rows
12851383 resolved_columns = columns
12861384 resolved_values = values
12871385
12881386 if resolved_rows is None and resolved_columns is None and resolved_values is None :
1289- numeric_cols = data .select_dtypes (include = "number" ).columns .tolist ()
1290- categorical_cols = [c for c in data .columns if c not in numeric_cols ]
1387+ numeric_cols = filtered_data .select_dtypes (include = "number" ).columns .tolist ()
1388+ categorical_cols = [c for c in filtered_data .columns if c not in numeric_cols ]
12911389 # Heuristic: numeric columns with few unique values (<=20) likely
12921390 # represent dimensions (e.g. Year) rather than measures.
1293- likely_measures = [c for c in numeric_cols if data [c ].nunique () > 20 ]
1294- likely_numeric_dims = [c for c in numeric_cols if data [c ].nunique () <= 20 ]
1391+ likely_measures = [c for c in numeric_cols if filtered_data [c ].nunique () > 20 ]
1392+ likely_numeric_dims = [
1393+ c for c in numeric_cols if filtered_data [c ].nunique () <= 20
1394+ ]
12951395 # Treat low-cardinality numerics as dimensions alongside categoricals
12961396 all_dims = categorical_cols + likely_numeric_dims
12971397 resolved_rows = all_dims [:1 ] if all_dims else []
@@ -1484,7 +1584,7 @@ def st_pivot_table(
14841584 # reruns, but let explicit Python config changes take precedence.
14851585 config_to_send = _resolve_config_to_send (st .session_state , key , initial_config )
14861586 use_threshold_hybrid , threshold_reason = _should_use_threshold_hybrid (
1487- data , config_to_send , execution_mode
1587+ filtered_data , config_to_send , execution_mode
14881588 )
14891589 if use_threshold_hybrid :
14901590 drill_note = (
@@ -1494,9 +1594,9 @@ def st_pivot_table(
14941594 if drill_note not in threshold_reason :
14951595 threshold_reason = f"{ threshold_reason } { drill_note } "
14961596 materialized_data = (
1497- _prepare_threshold_hybrid_frame (data , config_to_send , null_handling )
1597+ _prepare_threshold_hybrid_frame (filtered_data , config_to_send , null_handling )
14981598 if use_threshold_hybrid
1499- else data
1599+ else filtered_data
15001600 )
15011601 effective_execution_mode = (
15021602 "threshold_hybrid" if use_threshold_hybrid else "client_only"
@@ -1513,12 +1613,14 @@ def st_pivot_table(
15131613 }
15141614
15151615 if use_threshold_hybrid :
1516- data_payload ["source_row_count" ] = len (data )
1616+ data_payload ["source_row_count" ] = len (filtered_data )
15171617 agg_dict = config_to_send .get ("aggregation" , {})
15181618 agg_remap = _build_hybrid_agg_remap (agg_dict )
15191619 if agg_remap :
15201620 data_payload ["hybrid_agg_remap" ] = agg_remap
1521- totals_sidecar = _compute_hybrid_totals (data , config_to_send , null_handling )
1621+ totals_sidecar = _compute_hybrid_totals (
1622+ filtered_data , config_to_send , null_handling
1623+ )
15221624 if totals_sidecar :
15231625 data_payload ["hybrid_totals" ] = totals_sidecar
15241626
@@ -1568,7 +1670,7 @@ def st_pivot_table(
15681670 config_to_send .get ("columns" , [])
15691671 )
15701672 records , columns , total , page = _compute_hybrid_drilldown (
1571- data ,
1673+ filtered_data ,
15721674 drilldown_request ,
15731675 null_handling = null_handling ,
15741676 dims = all_dims ,
0 commit comments