narwhals-datafusion 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ """Apache DataFusion backend for Narwhals, registered via the `narwhals.plugins` entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ import datafusion
9
+ from narwhals._utils import Version
10
+ from typing_extensions import TypeIs
11
+
12
+ from narwhals_datafusion.namespace import DataFusionNamespace
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ NATIVE_PACKAGE = "datafusion"
17
+
18
+
19
+ def __narwhals_namespace__(version: Version) -> DataFusionNamespace: # noqa: N807
20
+ from narwhals_datafusion.namespace import DataFusionNamespace
21
+
22
+ return DataFusionNamespace(version=version)
23
+
24
+
25
+ def is_native(native_object: object) -> TypeIs[datafusion.DataFrame]:
26
+ import datafusion
27
+
28
+ return isinstance(native_object, datafusion.DataFrame)
@@ -0,0 +1,438 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import reduce
4
+ from operator import and_
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import pyarrow as pa
9
+ from datafusion import functions as F
10
+ from narwhals._sql.dataframe import SQLLazyFrame
11
+ from narwhals._utils import (
12
+ Implementation,
13
+ Version,
14
+ extend_bool,
15
+ generate_temporary_column_name,
16
+ not_implemented,
17
+ parse_columns_to_drop,
18
+ )
19
+ from narwhals.exceptions import InvalidOperationError
20
+
21
+ from narwhals_datafusion.utils import (
22
+ BACKEND_VERSION,
23
+ catch_datafusion_exception,
24
+ col,
25
+ evaluate_exprs_and_aliases,
26
+ lit,
27
+ native_to_narwhals_dtype,
28
+ sort_expr,
29
+ window_expression,
30
+ )
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Iterable, Iterator, Mapping, Sequence
34
+ from io import BytesIO
35
+ from types import ModuleType
36
+
37
+ import datafusion
38
+ from datafusion.expr import Expr
39
+ from narwhals._compliant.typing import CompliantDataFrameAny
40
+ from narwhals._typing import _EagerAllowedImpl
41
+ from narwhals._utils import _LimitedContext
42
+ from narwhals.dataframe import LazyFrame
43
+ from narwhals.dtypes import DType
44
+ from narwhals.typing import JoinStrategy, UniqueKeepStrategy
45
+ from typing_extensions import Self, TypeIs
46
+
47
+ from narwhals_datafusion.expr import DataFusionExpr
48
+ from narwhals_datafusion.group_by import DataFusionGroupBy
49
+ from narwhals_datafusion.namespace import DataFusionNamespace
50
+
51
+
52
+ class DataFusionLazyFrame(
53
+ SQLLazyFrame["DataFusionExpr", "datafusion.DataFrame", "LazyFrame[datafusion.DataFrame]"]
54
+ ):
55
+ _implementation = Implementation.UNKNOWN
56
+
57
+ def __init__(self, df: datafusion.DataFrame, *, version: Version) -> None:
58
+ self._native_frame: datafusion.DataFrame = df
59
+ self._version = version
60
+ self._cached_native_schema: pa.Schema | None = None
61
+ self._cached_columns: list[str] | None = None
62
+
63
+ @property
64
+ def _backend_version(self) -> tuple[int, ...]:
65
+ return BACKEND_VERSION
66
+
67
+ @staticmethod
68
+ def _is_native(obj: datafusion.DataFrame | Any) -> TypeIs[datafusion.DataFrame]:
69
+ import datafusion
70
+
71
+ return isinstance(obj, datafusion.DataFrame)
72
+
73
+ @classmethod
74
+ def from_native(cls, data: datafusion.DataFrame, /, *, context: _LimitedContext) -> Self:
75
+ return cls(data, version=context._version)
76
+
77
+ def to_narwhals(self) -> LazyFrame[datafusion.DataFrame]:
78
+ return self._version.lazyframe(self, level="lazy")
79
+
80
+ def __narwhals_lazyframe__(self) -> Self:
81
+ return self
82
+
83
+ def __native_namespace__(self) -> ModuleType:
84
+ import datafusion
85
+
86
+ return datafusion
87
+
88
+ def __narwhals_namespace__(self) -> DataFusionNamespace:
89
+ from narwhals_datafusion.namespace import DataFusionNamespace
90
+
91
+ return DataFusionNamespace(version=self._version)
92
+
93
+ def _iter_columns(self) -> Iterator[Expr]:
94
+ for name in self.columns:
95
+ yield col(name)
96
+
97
+ @property
98
+ def _native_schema(self) -> pa.Schema:
99
+ if self._cached_native_schema is None:
100
+ self._cached_native_schema = self.native.schema()
101
+ return self._cached_native_schema
102
+
103
+ @property
104
+ def schema(self) -> dict[str, DType]:
105
+ schema = self._native_schema
106
+ return {field.name: native_to_narwhals_dtype(field.type, self._version) for field in schema}
107
+
108
+ def collect_schema(self) -> dict[str, DType]:
109
+ return self.schema
110
+
111
+ @property
112
+ def columns(self) -> list[str]:
113
+ if self._cached_columns is None:
114
+ self._cached_columns = list(self._native_schema.names)
115
+ return self._cached_columns
116
+
117
+ def _with_version(self, version: Version) -> Self:
118
+ return self.__class__(self.native, version=version)
119
+
120
+ def _with_native(self, df: datafusion.DataFrame) -> Self:
121
+ return self.__class__(df, version=self._version)
122
+
123
+ def collect(self, backend: _EagerAllowedImpl | None, **kwargs: Any) -> CompliantDataFrameAny:
124
+ if backend is None or backend is Implementation.PYARROW:
125
+ from narwhals._arrow.dataframe import ArrowDataFrame
126
+
127
+ return ArrowDataFrame(
128
+ self.native.to_arrow_table(),
129
+ validate_backend_version=True,
130
+ version=self._version,
131
+ validate_column_names=True,
132
+ )
133
+
134
+ if backend is Implementation.PANDAS:
135
+ from narwhals._pandas_like.dataframe import PandasLikeDataFrame
136
+
137
+ return PandasLikeDataFrame(
138
+ self.native.to_pandas(),
139
+ implementation=Implementation.PANDAS,
140
+ validate_backend_version=True,
141
+ version=self._version,
142
+ validate_column_names=True,
143
+ )
144
+
145
+ if backend is Implementation.POLARS:
146
+ from narwhals._polars.dataframe import PolarsDataFrame
147
+
148
+ return PolarsDataFrame(
149
+ self.native.to_polars(),
150
+ validate_backend_version=True,
151
+ version=self._version,
152
+ )
153
+
154
+ msg = f"Unsupported `backend` value: {backend}" # pragma: no cover
155
+ raise ValueError(msg) # pragma: no cover
156
+
157
+ def head(self, n: int) -> Self:
158
+ return self._with_native(self.native.limit(n))
159
+
160
+ def simple_select(self, *column_names: str) -> Self:
161
+ return self._with_native(self.native.select(*(col(c) for c in column_names)))
162
+
163
+ def aggregate(self, *exprs: DataFusionExpr) -> Self:
164
+ selection = [value.alias(name) for name, value in evaluate_exprs_and_aliases(self, *exprs)]
165
+ try:
166
+ return self._with_native(self.native.aggregate([], selection))
167
+ except Exception as e: # noqa: BLE001
168
+ raise catch_datafusion_exception(e, self) from None
169
+
170
+ def select(self, *exprs: DataFusionExpr) -> Self:
171
+ selection = [value.alias(name) for name, value in evaluate_exprs_and_aliases(self, *exprs)]
172
+ try:
173
+ return self._with_native(self.native.select(*selection))
174
+ except Exception as e: # noqa: BLE001
175
+ raise catch_datafusion_exception(e, self) from None
176
+
177
+ def with_columns(self, *exprs: DataFusionExpr) -> Self:
178
+ new_columns_map = dict(evaluate_exprs_and_aliases(self, *exprs))
179
+ result = [
180
+ new_columns_map.pop(name).alias(name) if name in new_columns_map else col(name)
181
+ for name in self.columns
182
+ ]
183
+ result.extend(value.alias(name) for name, value in new_columns_map.items())
184
+ try:
185
+ return self._with_native(self.native.select(*result))
186
+ except Exception as e: # noqa: BLE001
187
+ raise catch_datafusion_exception(e, self) from None
188
+
189
+ def _filter(self, predicate: DataFusionExpr) -> Self:
190
+ mask = predicate(self)[0]
191
+ try:
192
+ return self._with_native(self.native.filter(mask))
193
+ except Exception as e:
194
+ raise catch_datafusion_exception(e, self) from e
195
+
196
+ def drop(self, columns: Sequence[str], *, strict: bool) -> Self:
197
+ columns_to_drop = parse_columns_to_drop(self, columns, strict=strict)
198
+ selection = (name for name in self.columns if name not in columns_to_drop)
199
+ return self.simple_select(*selection)
200
+
201
+ def drop_nulls(self, subset: Sequence[str] | None) -> Self:
202
+ subset_ = subset if subset is not None else self.columns
203
+ keep_condition = reduce(and_, (col(name).is_not_null() for name in subset_))
204
+ return self._with_native(self.native.filter(keep_condition))
205
+
206
+ def rename(self, mapping: Mapping[str, str]) -> Self:
207
+ selection = (
208
+ col(name).alias(mapping[name]) if name in mapping else col(name)
209
+ for name in self.columns
210
+ )
211
+ return self._with_native(self.native.select(*selection))
212
+
213
+ def group_by(
214
+ self, keys: Sequence[str] | Sequence[DataFusionExpr], *, drop_null_keys: bool
215
+ ) -> DataFusionGroupBy:
216
+ from narwhals_datafusion.group_by import DataFusionGroupBy
217
+
218
+ return DataFusionGroupBy(self, keys, drop_null_keys=drop_null_keys)
219
+
220
+ def sort(self, *by: str, descending: bool | Sequence[bool], nulls_last: bool) -> Self:
221
+ descending_flags = extend_bool(descending, len(by))
222
+ keys = [
223
+ sort_expr(name, descending=desc, nulls_last=nulls_last)
224
+ for name, desc in zip(by, descending_flags, strict=True)
225
+ ]
226
+ return self._with_native(self.native.sort(*keys))
227
+
228
+ def top_k(self, k: int, *, by: Iterable[str], reverse: bool | Sequence[bool]) -> Self:
229
+ by = list(by)
230
+ if isinstance(reverse, bool):
231
+ descending = extend_bool(not reverse, len(by))
232
+ else:
233
+ descending = tuple(not rev for rev in reverse)
234
+ tmp_name = generate_temporary_column_name(8, self.columns, prefix="top_k_")
235
+ rank = window_expression(
236
+ F.row_number(),
237
+ order_by=by,
238
+ descending=descending,
239
+ nulls_last=extend_bool(True, len(by)),
240
+ )
241
+ with_rank = self.native.select(*(col(name) for name in self.columns), rank.alias(tmp_name))
242
+ return self._with_native(with_rank.filter(col(tmp_name) <= lit(k))).drop(
243
+ [tmp_name], strict=False
244
+ )
245
+
246
+ def unique(
247
+ self,
248
+ subset: Sequence[str] | None,
249
+ *,
250
+ keep: UniqueKeepStrategy,
251
+ order_by: Sequence[str] | None,
252
+ ) -> Self:
253
+ subset_ = subset or self.columns
254
+ if error := self._check_columns_exist(subset_):
255
+ raise error
256
+ tmp_name = generate_temporary_column_name(8, self.columns, prefix="row_index_")
257
+ flags = extend_bool(True, len(order_by)) if order_by and keep == "last" else None
258
+ if keep == "none":
259
+ # The group-size count must ignore `order_by`: with ORDER BY the
260
+ # window defaults to a cumulative frame, turning this into a
261
+ # running count that keeps the first row of every duplicated group.
262
+ expr = window_expression(F.count_star(), subset_)
263
+ else:
264
+ expr = window_expression(
265
+ F.row_number(),
266
+ subset_,
267
+ order_by or (),
268
+ descending=flags,
269
+ nulls_last=flags,
270
+ )
271
+ with_marker = self.native.select(
272
+ *(col(name) for name in self.columns), expr.alias(tmp_name)
273
+ )
274
+ return self._with_native(with_marker.filter(col(tmp_name) == lit(1))).drop(
275
+ [tmp_name], strict=False
276
+ )
277
+
278
+ def join(
279
+ self,
280
+ other: Self,
281
+ *,
282
+ how: JoinStrategy,
283
+ left_on: Sequence[str] | None,
284
+ right_on: Sequence[str] | None,
285
+ suffix: str,
286
+ ) -> Self:
287
+ left_columns = self.columns
288
+ right_columns = other.columns
289
+
290
+ if how in {"semi", "anti"}:
291
+ assert left_on is not None # noqa: S101
292
+ assert right_on is not None # noqa: S101
293
+ joined = self.native.join(
294
+ other.native,
295
+ left_on=list(left_on),
296
+ right_on=list(right_on),
297
+ how=how,
298
+ )
299
+ # semi/anti joins keep left columns only.
300
+ return self._with_native(joined.select(*(col(c) for c in left_columns)))
301
+
302
+ # Rename every right-hand column to a collision-free temporary name so
303
+ # references stay unambiguous, then re-select with narwhals' suffix rules.
304
+ tmp_names = {
305
+ name: generate_temporary_column_name(
306
+ 8, [*left_columns, *right_columns], prefix=f"join_{i}_"
307
+ )
308
+ for i, name in enumerate(right_columns)
309
+ }
310
+ rhs = other.native.select(*(col(name).alias(tmp_names[name]) for name in right_columns))
311
+
312
+ if how == "cross":
313
+ joined = self.native.join_on(rhs, lit(True), how="inner")
314
+ else:
315
+ assert left_on is not None # noqa: S101
316
+ assert right_on is not None # noqa: S101
317
+ joined = self.native.join(
318
+ rhs,
319
+ left_on=list(left_on),
320
+ right_on=[tmp_names[name] for name in right_on],
321
+ how="full" if how == "full" else how,
322
+ coalesce_duplicate_keys=False,
323
+ )
324
+
325
+ selection = [col(name) for name in left_columns]
326
+ for name in right_columns:
327
+ in_left = name in left_columns
328
+ renamed = col(tmp_names[name])
329
+ if how == "full":
330
+ if in_left:
331
+ selection.append(renamed.alias(f"{name}{suffix}"))
332
+ else:
333
+ selection.append(renamed.alias(name))
334
+ elif right_on is not None and name in right_on:
335
+ continue # key columns are kept from the left side only
336
+ elif in_left:
337
+ selection.append(renamed.alias(f"{name}{suffix}"))
338
+ else:
339
+ selection.append(renamed.alias(name))
340
+
341
+ try:
342
+ return self._with_native(joined.select(*selection))
343
+ except Exception as e: # noqa: BLE001
344
+ raise catch_datafusion_exception(e, self) from None
345
+
346
+ def explode(self, columns: Sequence[str]) -> Self:
347
+ dtypes = self._version.dtypes
348
+ schema = self.collect_schema()
349
+ for name in columns:
350
+ dtype = schema[name]
351
+ if dtype != dtypes.List:
352
+ msg = f"`explode` operation not supported for dtype `{dtype}`, expected List type"
353
+ raise InvalidOperationError(msg)
354
+
355
+ if len(columns) != 1:
356
+ msg = (
357
+ "Exploding on multiple columns is not supported with the DataFusion backend since "
358
+ "we cannot guarantee that the exploded columns have matching element counts."
359
+ )
360
+ raise NotImplementedError(msg)
361
+
362
+ name = columns[0]
363
+ original_columns = self.columns
364
+ inner_type = self._native_schema.field(name).type.value_type
365
+
366
+ # datafusion's `unnest_columns` drops rows with empty lists and keeps
367
+ # nulls only via `preserve_nulls`; to match polars, route both empty
368
+ # and null lists through a literal-null branch and union positionally.
369
+ not_null_condition = col(name).is_not_null() & (F.array_length(col(name)) > lit(0))
370
+ non_null_rel = (
371
+ self.native.filter(not_null_condition)
372
+ .unnest_columns(name)
373
+ .select(*(col(c) for c in original_columns))
374
+ )
375
+ null_rel = self.native.filter(~not_null_condition).select(
376
+ *(
377
+ lit(None).cast(inner_type).alias(c) if c == name else col(c)
378
+ for c in original_columns
379
+ )
380
+ )
381
+ return self._with_native(non_null_rel.union(null_rel))
382
+
383
+ def unpivot(
384
+ self,
385
+ on: Sequence[str] | None,
386
+ index: Sequence[str] | None,
387
+ variable_name: str,
388
+ value_name: str,
389
+ ) -> Self:
390
+ index_ = [] if index is None else list(index)
391
+ on_ = [c for c in self.columns if c not in index_] if on is None else list(on)
392
+
393
+ # `union` requires matching schemas, so promote mixed numeric value
394
+ # columns to a common type up front.
395
+ schema = self._native_schema
396
+ value_types = {schema.field(name).type for name in on_}
397
+ target: pa.DataType | None = None
398
+ if len(value_types) > 1:
399
+ if all(pa.types.is_integer(tp) for tp in value_types):
400
+ target = pa.int64()
401
+ elif all(pa.types.is_integer(tp) or pa.types.is_floating(tp) for tp in value_types):
402
+ target = pa.float64()
403
+
404
+ def value_expr(name: str) -> Expr:
405
+ expr = col(name)
406
+ return expr.cast(target) if target is not None else expr
407
+
408
+ # No native unpivot: build one projection per value column and union.
409
+ frames = [
410
+ self.native.select(
411
+ *(col(c) for c in index_),
412
+ lit(name).alias(variable_name),
413
+ value_expr(name).alias(value_name),
414
+ )
415
+ for name in on_
416
+ ]
417
+ native = reduce(lambda left, right: left.union(right), frames)
418
+ return self._with_native(native)
419
+
420
+ def with_row_index(self, name: str, order_by: Sequence[str]) -> Self:
421
+ if not order_by:
422
+ msg = "Must pass `order_by` to `with_row_index` for the DataFusion backend"
423
+ raise TypeError(msg)
424
+ expr = (window_expression(F.row_number(), order_by=order_by) - lit(1)).alias(name)
425
+ return self._with_native(self.native.select(expr, *(col(c) for c in self.columns)))
426
+
427
+ def sink_parquet(self, file: str | Path | BytesIO) -> None:
428
+ if not isinstance(file, (str, Path)):
429
+ # `write_parquet` takes a path; `str(buffer)` would silently write
430
+ # to a file named after the object's repr.
431
+ msg = (
432
+ "`sink_parquet` to a file-like object is not supported for the "
433
+ "DataFusion backend; pass a file path instead."
434
+ )
435
+ raise NotImplementedError(msg)
436
+ self.native.write_parquet(str(file))
437
+
438
+ join_asof = not_implemented()