andygrove commented on code in PR #1763:
URL: 
https://github.com/apache/datafusion-python/pull/1763#discussion_r4138317837


##########
crates/core/src/expr.rs:
##########
@@ -678,8 +705,8 @@ impl PyExpr {
                     null_treatment,

Review Comment:
   1b018456 keeps the direction for the aggregate, but this arm still builds 
the window from `agg_fn.func` and `agg_fn.params.args` only (lines 695-698), so 
the WITHIN GROUP ordering is dropped again and a descending percentile used as 
a window silently returns the ascending result. The `windows.md` workaround 
(pass `order_by` in the `Window`) can't bring it back, since a window never 
hands its ORDER BY to the accumulator:
   
   ```python
   from datafusion import SessionContext, col, functions as f
   from datafusion.expr import Window, WindowFrame
   
   ctx = SessionContext()
   df = ctx.from_pydict({"a": [1.0, 2.0, 3.0, 4.0, 5.0]}, name="t")
   desc = col("a").sort(ascending=False)
   whole = WindowFrame("rows", None, None)
   
   print(df.aggregate([], [f.percentile_cont(desc, 
0.25).alias("p")]).to_pydict())
   # {'p': [4.0]}
   print(df.select(f.percentile_cont(desc, 
0.25).over(Window()).alias("p")).to_pydict())
   # {'p': [2.0, 2.0, 2.0, 2.0, 2.0]}
   print(df.select(
       f.percentile_cont(desc, 0.25).over(Window(order_by=[desc], 
window_frame=whole)).alias("p")
   ).to_pydict())
   # {'p': [2.0, 2.0, 2.0, 2.0, 2.0]}
   ctx.sql("SELECT percentile_cont(0.25) WITHIN GROUP (ORDER BY a DESC) OVER () 
FROM t")
   # Error during planning: OVER and WITHIN GROUP clause cannot be used 
together. ...
   ```
   
   `quantile_cont` gives the same, and `approx_percentile_cont` gives 4.25 as 
an aggregate vs 1.75 over a window. SQL also rejects a plain aggregate ORDER BY 
with OVER ("Aggregate ORDER BY is not implemented for window functions"), so 
raising here when `agg_fn.params.order_by` is non-empty would match SQL and 
turn this, and the `order_by` part of #1764, into an error instead of a wrong 
answer. The upgrade guide's "They now match `WITHIN GROUP (ORDER BY ... DESC)`" 
could also say it's for aggregates only.



##########
crates/core/src/expr.rs:
##########
@@ -743,6 +770,69 @@ impl PyExpr {
     }
 }
 
+/// Start an [`ExprFuncBuilder`] that keeps the options already set on `expr`.
+///
+/// Upstream's `ExprFunctionExt` methods on an `Expr` start from an empty
+/// builder, so `build()` would reset every option not set again. The Python
+/// function wrappers already apply their keyword options, so chaining another
+/// builder method onto their result must not discard them.
+///
+/// A built window function always stores a concrete frame, so whether the user
+/// chose it is lost. A frame equal to the default for the current order-by is
+/// treated as unset, which depends only on the expression and so behaves the
+/// same after a copy, pickle, or round trip through protobuf or SQL.
+fn builder_from_expr(expr: &Expr) -> ExprFuncBuilder {
+    match expr {
+        Expr::AggregateFunction(agg) => {
+            let params = &agg.params;
+            let mut builder = 
expr.clone().null_treatment(params.null_treatment);
+            if !params.order_by.is_empty() {
+                builder = builder.order_by(params.order_by.clone());
+            }
+            if let Some(filter) = &params.filter {
+                builder = builder.filter(filter.as_ref().clone());
+            }
+            if params.distinct {
+                builder = builder.distinct();
+            }
+            builder
+        }
+        Expr::WindowFunction(window) => {
+            let params = &window.params;
+            let mut builder = 
expr.clone().null_treatment(params.null_treatment);
+            if !params.partition_by.is_empty() {
+                builder = builder.partition_by(params.partition_by.clone());
+            }
+            let has_order_by = !params.order_by.is_empty();
+            if has_order_by {
+                builder = builder.order_by(params.order_by.clone());
+            }
+            // A frame equal to the default `build()` derived from the 
order-by is
+            // left unset, so it is derived again from the final order-by. An
+            // absent and an empty order-by derive different frames but are 
both
+            // stored as empty, so either frame counts as the default here.
+            let is_default_frame = if has_order_by {

Review Comment:
   b4cad2d2 fixed the chain I gave, but this check keys off 
`params.order_by.is_empty()`, and decoding (copy, pickle, `from_bytes`) runs 
upstream's `regularize_order_bys`, which adds `ORDER BY UInt64(1)` to a RANGE 
frame that has no order-by. After a round trip the frame no longer looks like 
the default, so it's kept, and the copy chains differently from the original:
   
   ```python
   import copy
   import pickle
   from datafusion import Expr, SessionContext, col, functions as f
   from datafusion.expr import Window
   
   ctx = SessionContext()
   df = ctx.from_pydict({"i": [1, 1, 2, 3], "v": [1, 2, 3, 4]})
   
   def run(e):
       r = e.over(Window(order_by="i")).alias("r")
       return df.select(col("v"), 
r).sort(col("v")).collect_column("r").to_pylist()
   
   base = f.sum(col("v")).over(Window(order_by=[]))
   print(run(base))                              # [1, 3, 6, 10]
   print(run(copy.copy(base)))                   # [3, 3, 6, 10]
   print(run(pickle.loads(pickle.dumps(base))))  # [3, 3, 6, 10]
   print(run(Expr.from_bytes(base.to_bytes())))  # [3, 3, 6, 10]
   print(copy.copy(base))
   # Expr(sum(v) ORDER BY [UInt64(1) ASC NULLS LAST] RANGE BETWEEN UNBOUNDED 
PRECEDING AND CURRENT ROW)
   ```
   
   On main all four give `[1, 3, 6, 10]`. `windows.md` promises this case ("a 
copy, a pickled expression sent to a worker, or one parsed from SQL all chain 
the same way"), but 
`test_window_builder_default_frame_same_after_copy_and_pickle` only covers 
`WindowFrame("rows", None, None)`. The check probably needs to treat an 
`order_by` holding only that literal sort key the same as an empty one.



##########
python/datafusion/dataframe.py:
##########
@@ -1229,9 +1269,25 @@ def explain(
             Show plan with runtime metrics:
 
             >>> df.explain(analyze=True)  # doctest: +SKIP
+
+            Show only row-count metrics:
+
+            >>> from datafusion.dataframe import ExplainMetricCategory
+            >>> df.explain(
+            ...     analyze=True, 
analyze_categories=[ExplainMetricCategory.ROWS]
+            ... )  # doctest: +SKIP
         """
+        if analyze and show_statistics:

Review Comment:
   607dde49 rejects `show_statistics=True` with `analyze`, but 
`show_statistics=False` is still silently ignored (Analyze always uses the 
session setting), and `analyze_level`/`analyze_categories` are silently ignored 
without `analyze`. SQL rejects all three:
   
   ```python
   from datafusion import SessionConfig, SessionContext, col, lit
   from datafusion.dataframe import ExplainAnalyzeLevel, ExplainMetricCategory
   
   ctx = 
SessionContext(SessionConfig().set("datafusion.explain.show_statistics", 
"true"))
   df = ctx.from_pydict({"a": [1, 2, 3]}, name="t").filter(col("a") > lit(1))
   
   df.explain(analyze=True, show_statistics=False)
   # no error; the plan still has statistics=[Rows=Inexact(3), ...]
   df.explain(analyze_level=ExplainAnalyzeLevel.DEV)
   # no error; plain plan, no metrics
   df.explain(analyze_categories=[ExplainMetricCategory.ROWS])
   # no error; plain plan, no metrics
   ctx.sql("EXPLAIN (ANALYZE, COSTS OFF) SELECT a FROM t WHERE a > 1")
   # Error during planning: EXPLAIN option COSTS cannot be combined with ANALYZE
   ctx.sql("EXPLAIN (LEVEL dev) SELECT a FROM t WHERE a > 1")
   # Error during planning: EXPLAIN option LEVEL requires ANALYZE
   ctx.sql("EXPLAIN (METRICS 'rows') SELECT a FROM t WHERE a > 1")
   # Error during planning: EXPLAIN option METRICS requires ANALYZE
   ```
   
   Checking `show_statistics is not None`, and raising when `analyze_level` or 
`analyze_categories` is set without `analyze`, would match.



##########
crates/core/src/expr.rs:
##########
@@ -625,34 +625,61 @@ impl PyExpr {
     // Expression Function Builder functions
 
     pub fn order_by(&self, order_by: Vec<PySortExpr>) -> PyExprFuncBuilder {
-        self.expr
-            .clone()
+        builder_from_expr(&self.expr)
             .order_by(to_sort_expressions(order_by))
             .into()
     }
 
     pub fn filter(&self, filter: PyExpr) -> PyExprFuncBuilder {

Review Comment:
   92ace16b restores the error for the first call, but the check only runs 
there. `filter()` has no check, and the chained `PyExprFuncBuilder` methods 
have none either, so the same option later in a chain is still dropped. The PR 
description says "A builder option that does not apply to the function's kind 
still raises":
   
   ```python
   from datafusion import SessionContext, col, lit, functions as f
   
   ctx = SessionContext()
   df = ctx.from_pydict({"g": [1, 1, 2, 2], "v": [1, 2, 3, 4]})
   
   f.lead(col("v"), 1, order_by="v").distinct().build()
   # ExprFunctionExt can only be used with Expr::AggregateFunction or 
Expr::WindowFunction
   
   e = f.lead(col("v"), 1, 
order_by="v").partition_by(col("g")).distinct().build()
   print(e)  # Expr(lead(DISTINCT v, Int64(1), NULL) PARTITION BY [g] ORDER BY 
[v ASC NULLS FIRST] ...)
   print(df.select(col("v"), 
e.alias("r")).sort(col("v")).collect_column("r").to_pylist())
   # [2, None, 4, None], DISTINCT ignored
   
   e = f.sum(col("v")).filter(col("v") > lit(1)).partition_by(col("g")).build()
   print(df.aggregate([], [e.alias("s")]).to_pydict())
   # {'s': [9]}, partition_by ignored
   
   e = f.row_number(order_by=[col("v")]).filter(col("v") > lit(2)).build()
   # main: raises here; PR: builds
   df.select(e.alias("r"))
   # Error during planning: FILTER clause can only be used with aggregate 
window functions. ...
   ```
   
   The two chained cases drop the option on main too, so only `filter()` is a 
regression, and it still errors, just later. Recording the function kind on 
`PyExprFuncBuilder` and checking there would cover every entry point with one 
rule.



##########
python/datafusion/functions/__init__.py:
##########
@@ -7091,14 +7618,29 @@ def string_agg(
         ...     ).alias("s")])
         >>> result.collect_column("s")[0].as_py()
         'y,z'
+
+        >>> df = ctx.from_pydict({"a": ["y", "x", "y"]})
+        >>> result = df.aggregate(
+        ...     [], [dfn.functions.string_agg(
+        ...         dfn.col("a"), ",", distinct=True, order_by="a",
+        ...     ).alias("s")])
+        >>> result.collect_column("s")[0].as_py()
+        'x,y'
     """
+    if not isinstance(distinct, bool):

Review Comment:
   This also rejects `numpy.bool_`, which every other aggregate's `distinct` 
accepts through PyO3's `Option<bool>`, and the message then says "got bool":
   
   ```python
   import numpy as np
   from datafusion import SessionContext, col, functions as f
   
   df = SessionContext().from_pydict({"s": ["x", "y", "x"]})
   print(df.aggregate([], [f.count(col("s"), 
distinct=np.True_).alias("n")]).to_pydict())
   # {'n': [2]}
   f.string_agg(col("s"), ",", distinct=np.True_)
   # TypeError: distinct must be a bool, got bool; pass filter and order_by by 
keyword
   ```
   
   A shifted positional call can only put `None` or an `Expr` here, so 
rejecting just those two would keep the fix from 0ac494e6 and leave everything 
else to PyO3.



##########
python/datafusion/user_defined.py:
##########
@@ -388,7 +397,9 @@ def wrapper(*args: Any, **kwargs: Any) -> Callable:
 
             return decorator
 
-        if hasattr(args[0], "__datafusion_scalar_udf__"):
+        if args and (
+            hasattr(args[0], "__datafusion_scalar_udf__") or 
_is_pycapsule(args[0])
+        ):
             return ScalarUDF.from_pycapsule(args[0])
 
         if args and callable(args[0]):

Review Comment:
   Pre-existing, and f8d7b70f fixed the keyword-only decorator form, but the 
function form with the callable passed by keyword (declared by the second 
`@overload`) still falls through to the decorator factory. It now fails with a 
`TypeError` naming an internal helper instead of the old `IndexError`:
   
   ```python
   import pyarrow as pa
   import pyarrow.compute as pc
   from datafusion import udf
   
   def double(x: pa.Array) -> pa.Array:
       return pc.multiply(x, 2)
   
   udf(func=double, input_fields=[pa.int64()], return_field=pa.int64(), 
volatility="immutable")
   # main: IndexError: tuple index out of range
   # PR:   TypeError: ScalarUDF.udf.<locals>._decorator() got an unexpected 
keyword argument 'func'
   ```
   
   `udaf(accum=...)` and `udwf(func=...)` fail the same way. Taking `func` 
(`accum` for `udaf`) from `kwargs` when `args` is empty would cover it.



##########
python/datafusion/user_defined.py:
##########
@@ -31,7 +31,12 @@
 from datafusion.expr import Expr
 
 if TYPE_CHECKING:
-    from _typeshed import CapsuleType as _PyCapsule
+    import sys
+
+    if sys.version_info >= (3, 13):
+        from types import CapsuleType as _PyCapsule

Review Comment:
   This fixes the alias here, but `context.py:92` and `extensions.py:52` still 
import `CapsuleType` from `_typeshed`, which f6d04788's message notes doesn't 
define it, so the capsule half of those unions is still `Unknown`. With pyright 
1.1.414:
   
   ```text
   python/datafusion/context.py:92:27 - error: "CapsuleType" is unknown import 
symbol (reportAttributeAccessIssue)
   python/datafusion/extensions.py:52:27 - error: "CapsuleType" is unknown 
import symbol (reportAttributeAccessIssue)
   ```
   
   ```python
   from datafusion import SessionContext
   from datafusion.user_defined import ScalarUDF
   
   ctx = SessionContext()
   ctx.set_query_planner(1)             # pyright: no error
   ctx.with_logical_extension_codec(1)  # pyright: no error
   ScalarUDF.from_pycapsule(1)          # pyright: "Literal[1]" is not 
assignable to "CapsuleType"
   ```
   
   Smaller: since `_PyCapsule` only exists under `TYPE_CHECKING`, 
`typing.get_type_hints(ScalarUDF.from_pycapsule)` (and `WindowUDF`'s) now 
raises `NameError: name '_PyCapsule' is not defined`; on main it resolved.



##########
python/datafusion/dataframe.py:
##########
@@ -1868,13 +1925,51 @@ def fill_null(self, value: Any, subset: list[str] | 
None = None) -> DataFrame:
             >>> filled.sort(col("a")).collect()[0].column("a").to_pylist()
             [0, 1, 3]
 
+            >>> df.fill_null(0, subset=[]).to_pydict()
+            {'a': [1, None, 3], 'b': [None, 5, 6]}
+
         Notes:
             - Only fills nulls in columns where the value can be cast to the 
column type
             - For columns where casting fails, the original column is kept 
unchanged
             - For columns not in subset, the original column is kept unchanged
         """
+        if subset is not None and not subset:

Review Comment:
   `not subset` takes the truth value of `subset`, so column lists from numpy 
or pandas now raise here (and in `fill_nan` below). The Rust binding accepts 
any sequence of strings, so these worked on main:
   
   ```python
   import numpy as np
   import pandas as pd
   from datafusion import SessionContext
   
   df = SessionContext().from_pydict({"a": [1, None, 3], "b": [None, 5, 6], 
"c": [None, None, 9]})
   cols = pd.DataFrame(columns=["a", "b"]).columns
   
   print(df.fill_null(0, subset=cols).to_pydict())
   # main: {'a': [1, 0, 3], 'b': [0, 5, 6], 'c': [None, None, 9]}
   # PR:   ValueError: The truth value of a Index is ambiguous. ...
   print(df.fill_null(0, subset=np.array(["a", "b"])).to_pydict())
   # main: {'a': [1, 0, 3], 'b': [0, 5, 6], 'c': [None, None, 9]}
   # PR:   ValueError: The truth value of an array with more than one element 
is ambiguous. ...
   ```
   
   `if subset is not None and len(subset) == 0:` keeps the new empty-subset 
behavior without this.



##########
python/datafusion/functions/__init__.py:
##########
@@ -5110,19 +5539,22 @@ def approx_percentile_cont_with_weight(
 def percentile_cont(
     sort_expression: Expr | SortExpr,
     percentile: float,
+    distinct: bool = False,
     filter: Expr | None = None,
 ) -> Expr:
     """Computes the exact percentile of input values using continuous 
interpolation.
 
     Unlike :py:func:`approx_percentile_cont`, this function computes the exact
     percentile value rather than an approximation.
 
-    If using the builder functions described in ref:`_aggregation` this 
function ignores
-    the options ``order_by``, ``null_treatment``, and ``distinct``.
+    If using the builder functions described in :ref:`aggregation` this 
function ignores
+    the option ``null_treatment``, and ``order_by`` replaces the ordering of

Review Comment:
   Only the direction of the first `order_by` key is used. The percentile is 
still computed over `sort_expression`, while the output name shows the new 
column, so `order_by` doesn't replace the ordering the way this reads (same 
text at 5447 and 5497):
   
   ```python
   from datafusion import SessionContext, col, functions as f
   
   ctx = SessionContext()
   df = ctx.from_pydict({"a": [1.0, 2.0, 3.0, 4.0, 5.0], "b": [10.0, 20.0, 
30.0, 40.0, 50.0]}, name="t")
   
   e = f.percentile_cont(col("a"), 0.25).order_by(col("b")).build()
   print(df.aggregate([], [e]).to_pydict())
   # {'percentile_cont(Float64(0.25)) WITHIN GROUP [t.b ASC NULLS FIRST]': 
[2.0]}
   print(ctx.sql("SELECT percentile_cont(0.25) WITHIN GROUP (ORDER BY b) FROM 
t").to_pydict())
   # {'percentile_cont(Float64(0.25)) WITHIN GROUP [t.b ASC NULLS LAST]': 
[20.0]}
   ```
   
   The approx variants give 1.75 vs 17.5. The behavior isn't new, but the 
docstring now invites it. Saying that `order_by` only sets the direction and 
should use the same expression would be accurate.



##########
python/datafusion/functions/__init__.py:
##########
@@ -2973,18 +3126,57 @@ def array(*args: Expr) -> Expr:
     return make_array(*args)
 
 
-def range(start: Expr, stop: Expr, step: Expr) -> Expr:
-    """Create a list of values in the range between start and stop.
+def _series(
+    fn: Callable[..., Any],
+    name: str,
+    start: Expr | int,
+    stop: Expr | int | None,
+    step: Expr | int | None,
+) -> Expr:
+    if stop is None and step is not None:
+        msg = f"{name}() requires stop when step is given"
+        raise ValueError(msg)
+    stop = coerce_to_expr_or_none(stop)
+    step = coerce_to_expr_or_none(step)
+    return Expr(
+        fn(
+            coerce_to_expr(start).expr,
+            stop.expr if stop is not None else None,
+            step.expr if step is not None else None,
+        )
+    )
+
+
+def range(

Review Comment:
   A lone argument is now the upper bound, but it binds to the parameter named 
`start`, which can be passed by keyword. So `range(start=5)`, which used to 
raise because `stop` was missing, now silently means `stop=5`, while 
`range(stop=5)` raises:
   
   ```python
   from datafusion import SessionContext, col, functions as f
   
   df = SessionContext().from_pydict({"lo": [3]})
   print(df.select(f.range(start=5).alias("r")).to_pydict())
   # main: TypeError: range() missing 2 required positional arguments: 'stop' 
and 'step'
   # PR:   {'r': [[0, 1, 2, 3, 4]]}
   print(df.select(f.gen_series(start=col("lo")).alias("r")).to_pydict())
   # main: TypeError: gen_series() missing 1 required positional argument: 
'stop'
   # PR:   {'r': [[0, 1, 2, 3]]}
   f.range(stop=5)
   # PR: TypeError: range() missing 1 required positional argument: 'start'
   ```
   
   Python's `range` avoids this by not taking keywords, and `numpy.arange` 
treats a lone `stop=` as the bound; either would keep a value named `start` 
from becoming the end. Separately, `series_expr` in `functions.rs` chains 
`(start, None, step)` into a two-argument call, so 
`_internal.functions.range(lit(10).expr, None, lit(3).expr)` builds 
`range(Int64(10), Int64(3))` and returns `[]`. Only `_series` guards that today.



##########
crates/core/src/expr.rs:
##########
@@ -625,34 +625,61 @@ impl PyExpr {
     // Expression Function Builder functions
 
     pub fn order_by(&self, order_by: Vec<PySortExpr>) -> PyExprFuncBuilder {
-        self.expr
-            .clone()
+        builder_from_expr(&self.expr)
             .order_by(to_sort_expressions(order_by))
             .into()
     }
 
     pub fn filter(&self, filter: PyExpr) -> PyExprFuncBuilder {
-        self.expr.clone().filter(filter.expr.clone()).into()
+        builder_from_expr(&self.expr)
+            .filter(filter.expr.clone())
+            .into()
     }
 
     pub fn distinct(&self) -> PyExprFuncBuilder {
-        self.expr.clone().distinct().into()
+        // Only aggregates support DISTINCT, including an aggregate run as a 
window
+        // function. For anything else, upstream's empty builder makes 
`build()`
+        // raise instead of dropping the option.
+        let supports_distinct = match &self.expr {
+            Expr::AggregateFunction(_) => true,
+            Expr::WindowFunction(window) => {
+                matches!(window.fun, WindowFunctionDefinition::AggregateUDF(_))

Review Comment:
   Accepting `distinct()` on an aggregate window is what makes the #1764 
workaround in `windows.md` work 
(`f.avg(col("v")).over(Window()).distinct().build()`). But Python UDAFs ignore 
`AccumulatorArgs.is_distinct` (`udaf.rs:305`), so for them the recommended 
pattern silently returns the non-distinct result:
   
   ```python
   import pyarrow as pa
   import pyarrow.compute as pc
   from datafusion import Accumulator, SessionContext, col, udaf, functions as f
   from datafusion.expr import Window
   
   class MySum(Accumulator):
       def __init__(self) -> None:
           self._sum = 0.0
   
       def update(self, values: pa.Array) -> None:
           self._sum += pc.sum(values).as_py() or 0.0
   
       def merge(self, states: list[pa.Array]) -> None:
           self._sum += pc.sum(states[0]).as_py() or 0.0
   
       def state(self) -> list[pa.Scalar]:
           return [pa.scalar(self._sum)]
   
       def evaluate(self) -> pa.Scalar:
           return pa.scalar(self._sum)
   
   my_sum = udaf(MySum, pa.float64(), pa.float64(), [pa.float64()], "immutable")
   df = SessionContext().from_pydict({"v": [1.0, 1.0, 1.0, 5.0]})
   
   
print(df.select(my_sum(col("v")).over(Window()).distinct().build().alias("r")).to_pydict())
   # {'r': [8.0, 8.0, 8.0, 8.0]}
   
print(df.select(f.sum(col("v")).over(Window()).distinct().build().alias("r")).to_pydict())
   # {'r': [6.0, 6.0, 6.0, 6.0]}
   ```
   
   The UDAF side is pre-existing: SQL `my_sum(DISTINCT v) OVER ()` also gives 
8.0, on main too. But this PR adds a new path to it and recommends that path. A 
caveat in `windows.md`, or having the Python UDAF's `accumulator()` return an 
error when `is_distinct` is set, would keep the workaround from giving wrong 
answers.



##########
python/datafusion/functions/__init__.py:
##########
@@ -5065,8 +5493,9 @@ def approx_percentile_cont_with_weight(
     This aggregate function is similar to :py:func:`approx_percentile_cont` 
except that
     it uses the associated associated weights.
 
-    If using the builder functions described in ref:`_aggregation` this 
function ignores
-    the options ``order_by``, ``null_treatment``, and ``distinct``.
+    If using the builder functions described in :ref:`aggregation` this 
function ignores
+    the options ``null_treatment`` and ``distinct``, and ``order_by`` replaces 
the

Review Comment:
   This line was rewritten, but `distinct` isn't ignored here: upstream rejects 
it at execution. `count_star` (lines 961-962) makes the same kind of claim, and 
there the builder does apply it:
   
   ```python
   from datafusion import SessionContext, col, functions as f
   
   df = SessionContext().from_pydict({"a": [1.0, 2.0, 2.0, 3.0], "w": [1.0, 
1.0, 1.0, 1.0]})
   
   e = f.approx_percentile_cont_with_weight(col("a"), col("w"), 
0.5).distinct().build()
   df.aggregate([], [e.alias("p")]).collect()
   # This feature is not implemented: 
approx_percentile_cont_with_weight(DISTINCT) aggregations are not available
   
   e = f.count_star().distinct().build()
   print(e)                                             # Expr(count(DISTINCT 
Int64(1)))
   print(df.aggregate([], [e.alias("n")]).to_pydict())  # {'n': [1]}, not 4
   ```
   
   `approx_percentile_cont` really does ignore it, so only the `_with_weight` 
text is off. The `count_star` wording predates this PR.



##########
python/datafusion/dataframe.py:
##########
@@ -1868,13 +1925,51 @@ def fill_null(self, value: Any, subset: list[str] | 
None = None) -> DataFrame:
             >>> filled.sort(col("a")).collect()[0].column("a").to_pylist()
             [0, 1, 3]
 
+            >>> df.fill_null(0, subset=[]).to_pydict()
+            {'a': [1, None, 3], 'b': [None, 5, 6]}
+
         Notes:
             - Only fills nulls in columns where the value can be cast to the 
column type
             - For columns where casting fails, the original column is kept 
unchanged
             - For columns not in subset, the original column is kept unchanged
         """
+        if subset is not None and not subset:
+            return self
         return DataFrame(self.df.fill_null(value, subset))
 
+    def fill_nan(self, value: float, subset: list[str] | None = None) -> 
DataFrame:
+        """Fill NaN values in floating-point columns with a value.
+
+        Only floating-point columns are changed; others are kept unchanged, as 
is
+        any column ``value`` cannot be cast to. NaN is distinct from null, 
which
+        :py:meth:`fill_null` handles.
+
+        Args:
+            value: Value to replace NaN with. Will be cast to match column 
type.
+            subset: Optional list of column names to fill. If None, fills all
+                floating-point columns; an empty list fills none.
+
+        Returns:
+            DataFrame with NaN values replaced.
+
+        Examples:
+            >>> from datafusion import SessionContext
+            >>> ctx = SessionContext()
+            >>> nan = float("nan")
+            >>> df = ctx.from_pydict({"a": [1.0, nan, None], "b": [nan, 2.0, 
3.0]})
+            >>> df.fill_nan(0.0).to_pydict()
+            {'a': [1.0, 0.0, None], 'b': [0.0, 2.0, 3.0]}
+
+            >>> df.fill_nan(0.0, subset=["a"]).collect_column("b")[0].as_py()
+            nan
+
+            >>> df.fill_nan(0.0, subset=[]).collect_column("b")[0].as_py()
+            nan
+        """
+        if subset is not None and not subset:
+            return self
+        return DataFrame(self.df.fill_nan(value, subset))

Review Comment:
   On apache/datafusion#25829: leaving the wrapper as-is makes sense, but right 
now the limitation is only in the PR description. It hits any DataFrame with 
such a column, even when that column isn't in `subset` or isn't a float, so a 
note in the guide with a one-line pointer from the `fill_nan`/`fill_null` 
docstrings would save people some confusion:
   
   ```python
   from datafusion import SessionContext
   
   nan = float("nan")
   df = SessionContext().from_pydict({"Price": [nan, 2.0], "qty": [nan, 1.0]})
   df.fill_nan(0.0, subset=["qty"])
   # Schema error: No field named price. Did you mean '..."Price"'?
   ```



##########
docs/source/user-guide/upgrade-guides.md:
##########
@@ -198,6 +198,113 @@ ctx.execute(plan, partitions=0)  # before
 ctx.execute(plan, partition=0)  # after
 ```
 
+### More aggregate functions accept `distinct`
+
+{py:func}`~datafusion.functions.bit_and`,
+{py:func}`~datafusion.functions.bit_or`,
+{py:func}`~datafusion.functions.mean`,
+{py:func}`~datafusion.functions.percentile_cont`,
+{py:func}`~datafusion.functions.quantile_cont`, and
+{py:func}`~datafusion.functions.string_agg` now accept a `distinct` argument.
+As with `sum` and `avg` in 54.0.0, `distinct` is inserted *before* `filter`, so
+code that passed `filter` (or, for `string_agg`, `order_by`) positionally must
+pass it by keyword.
+
+```python
+f.bit_and(column("a"), my_filter)  # before
+f.bit_and(column("a"), filter=my_filter)  # after
+```
+
+Passing `filter` to `mean` previously raised a `TypeError`, whether passed

Review Comment:
   Nit: "whether passed positionally or by keyword; it now works" reads as if 
both now work, but a positional `filter` lands in the new `distinct` and still 
raises:
   
   ```python
   from datafusion import SessionContext, col, lit, functions as f
   
   df = SessionContext().from_pydict({"v": [1.0, 2.0, 3.0]})
   f.mean(col("v"), col("v") > lit(1.0))
   # TypeError: 'Expr' object is not an instance of 'bool'
   print(df.aggregate([], [f.mean(col("v"), filter=col("v") > 
lit(1.0)).alias("m")]).to_pydict())
   # {'m': [2.5]}
   ```
   
   "it now works when passed by keyword" would match.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to