betterframe 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.
- betterframe/__init__.py +38 -0
- betterframe/frame.py +113 -0
- betterframe/ops.py +190 -0
- betterframe-0.1.0.dist-info/METADATA +143 -0
- betterframe-0.1.0.dist-info/RECORD +7 -0
- betterframe-0.1.0.dist-info/WHEEL +4 -0
- betterframe-0.1.0.dist-info/licenses/LICENSE +21 -0
betterframe/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""BetterFrame: write a dataframe pipeline once, run it on Dask or pandas.
|
|
2
|
+
|
|
3
|
+
Not a DataFrame implementation. It adapts the *execution semantics* that differ
|
|
4
|
+
between engines -- per-partition application, meta schemas, aggregation
|
|
5
|
+
keywords, persistence -- so a pipeline that must work on both does not fill up
|
|
6
|
+
with ``if is_dask:`` branches.
|
|
7
|
+
|
|
8
|
+
Public API:
|
|
9
|
+
- :class:`BetterFrame` -- a frame bound to the operations its engine needs
|
|
10
|
+
- :class:`DaskOps` / :class:`PandasOps` -- subclass to add engine-specific
|
|
11
|
+
behaviour of your own, and pass to :class:`BetterFrame`
|
|
12
|
+
- :func:`is_dask_frame` -- engine test that does not import Dask needlessly
|
|
13
|
+
|
|
14
|
+
Example::
|
|
15
|
+
|
|
16
|
+
from betterframe import BetterFrame
|
|
17
|
+
|
|
18
|
+
frame = BetterFrame(records).mutable()
|
|
19
|
+
frame = frame.apply(normalise, meta=lambda: build_meta(frame.meta_source()))
|
|
20
|
+
result = frame.pipe(lambda df: df.groupby("key").agg(aggs, **frame.agg_kwargs()))
|
|
21
|
+
return result.finalize()
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from .frame import BetterFrame
|
|
27
|
+
from .ops import DaskOps, DataFrameOps, MetaSource, PandasOps, is_dask_frame
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"BetterFrame",
|
|
31
|
+
"DaskOps",
|
|
32
|
+
"DataFrameOps",
|
|
33
|
+
"MetaSource",
|
|
34
|
+
"PandasOps",
|
|
35
|
+
"is_dask_frame",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
betterframe/frame.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""``BetterFrame`` -- a frame bound to the operations its engine needs.
|
|
2
|
+
|
|
3
|
+
Every operation that differs between Dask and pandas takes the frame as its
|
|
4
|
+
first argument, so binding the frame removes that argument from every call and
|
|
5
|
+
lets a pipeline chain instead of repeating itself.
|
|
6
|
+
|
|
7
|
+
The wrapper is deliberately thin. It does not proxy the dataframe API: real
|
|
8
|
+
work still happens on the frame itself, reached through :attr:`native` or
|
|
9
|
+
:meth:`pipe`. Wrapping exists to answer engine questions, not to replace pandas.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
from .ops import DaskOps, DataFrameOps, PandasOps, is_dask_frame
|
|
17
|
+
|
|
18
|
+
__all__ = ["BetterFrame"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BetterFrame:
|
|
22
|
+
"""A dataframe together with the operations its engine requires.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
frame: A Dask or pandas DataFrame.
|
|
26
|
+
dask_ops: Operations class to use for Dask frames. Subclass
|
|
27
|
+
:class:`~betterframe.DaskOps` to add behaviour of your own.
|
|
28
|
+
pandas_ops: Operations class to use for pandas frames.
|
|
29
|
+
|
|
30
|
+
Methods that produce a frame return a new ``BetterFrame``, so a pipeline
|
|
31
|
+
chains. :meth:`finalize` ends the chain and hands back a native frame.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__slots__ = ("_frame", "_ops")
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
frame: Any,
|
|
39
|
+
*,
|
|
40
|
+
dask_ops: type[DataFrameOps] = DaskOps,
|
|
41
|
+
pandas_ops: type[DataFrameOps] = PandasOps,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._frame = frame
|
|
44
|
+
self._ops = dask_ops() if is_dask_frame(frame) else pandas_ops()
|
|
45
|
+
|
|
46
|
+
# --- access ---------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def native(self) -> Any:
|
|
50
|
+
"""The underlying dataframe, for operations that need no adaptation."""
|
|
51
|
+
return self._frame
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def ops(self) -> DataFrameOps:
|
|
55
|
+
"""The operations object, for engine questions that take no frame."""
|
|
56
|
+
return self._ops
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def is_dask(self) -> bool:
|
|
60
|
+
return self._ops.is_dask
|
|
61
|
+
|
|
62
|
+
def _wrap(self, frame: Any) -> BetterFrame:
|
|
63
|
+
new = object.__new__(BetterFrame)
|
|
64
|
+
new._frame = frame
|
|
65
|
+
new._ops = self._ops
|
|
66
|
+
return new
|
|
67
|
+
|
|
68
|
+
# --- operations -----------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def apply(
|
|
71
|
+
self, fn: Callable, *args: Any, meta: Callable | None = None, **kwargs: Any
|
|
72
|
+
) -> BetterFrame:
|
|
73
|
+
"""Apply a per-partition function.
|
|
74
|
+
|
|
75
|
+
``meta`` is a thunk rather than a value so Dask-only meta builders are
|
|
76
|
+
never invoked on a pandas frame.
|
|
77
|
+
"""
|
|
78
|
+
return self._wrap(self._ops.apply(self._frame, fn, *args, meta=meta, **kwargs))
|
|
79
|
+
|
|
80
|
+
def pipe(self, fn: Callable, *args: Any, **kwargs: Any) -> BetterFrame:
|
|
81
|
+
"""Run a function against the whole native frame and stay wrapped.
|
|
82
|
+
|
|
83
|
+
For operations that need no adaptation -- ``groupby``, ``rename`` and
|
|
84
|
+
the like -- where dropping to :attr:`native` and re-wrapping would only
|
|
85
|
+
add noise.
|
|
86
|
+
"""
|
|
87
|
+
return self._wrap(fn(self._frame, *args, **kwargs))
|
|
88
|
+
|
|
89
|
+
def mutable(self) -> BetterFrame:
|
|
90
|
+
"""A frame whose columns may be assigned without affecting the caller."""
|
|
91
|
+
return self._wrap(self._ops.mutable(self._frame))
|
|
92
|
+
|
|
93
|
+
def index_names(self) -> Any:
|
|
94
|
+
"""Names of the frame's index levels."""
|
|
95
|
+
return self._ops.index_names(self._frame)
|
|
96
|
+
|
|
97
|
+
def agg_kwargs(self) -> dict[str, Any]:
|
|
98
|
+
"""Extra keyword arguments for ``groupby().agg()`` on this engine."""
|
|
99
|
+
return self._ops.agg_kwargs(self._frame)
|
|
100
|
+
|
|
101
|
+
def meta_source(self) -> Any:
|
|
102
|
+
"""Something a Dask meta builder can read ``.columns`` and ``._meta`` from."""
|
|
103
|
+
return self._ops.meta_source(self._frame)
|
|
104
|
+
|
|
105
|
+
def finalize(self) -> Any:
|
|
106
|
+
"""Make the result concrete and return the native frame."""
|
|
107
|
+
return self._ops.finalize(self._frame)
|
|
108
|
+
|
|
109
|
+
# --- niceties -------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def __repr__(self) -> str:
|
|
112
|
+
engine = "dask" if self.is_dask else "pandas"
|
|
113
|
+
return f"BetterFrame({engine}, {type(self._frame).__name__})"
|
betterframe/ops.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Engine differences between Dask and pandas frames, isolated in one place.
|
|
2
|
+
|
|
3
|
+
An aggregation pipeline that must run on either engine otherwise fills up with
|
|
4
|
+
``if is_dask:`` branches. These classes hold the differences so the pipeline can
|
|
5
|
+
be written once. Most callers do not use them directly -- they go through
|
|
6
|
+
:class:`~betterframe.BetterFrame`, which binds a frame to the right one -- but
|
|
7
|
+
they are the extension point for adding engine-specific behaviour of your own.
|
|
8
|
+
|
|
9
|
+
Most of the surface is mechanical -- ``map_partitions(fn)`` versus ``fn(df)``,
|
|
10
|
+
``persist()`` versus nothing. The part worth reading is how the pandas side
|
|
11
|
+
emulates Dask's ``meta`` semantics, because getting it wrong produces frames
|
|
12
|
+
that differ only in dtype, only for empty inputs, and only sometimes:
|
|
13
|
+
|
|
14
|
+
* Dask coerces **every** partition to a meta schema. A function that
|
|
15
|
+
short-circuits on an empty frame and returns it unchanged therefore still
|
|
16
|
+
comes out with the populated schema, because Dask repairs it afterwards.
|
|
17
|
+
* Where no explicit meta is given, Dask *infers* one by running the function
|
|
18
|
+
against a synthetic non-empty frame. So even without a meta, an empty
|
|
19
|
+
partition ends up with the schema the function would have produced had there
|
|
20
|
+
been rows.
|
|
21
|
+
|
|
22
|
+
pandas does neither. :class:`PandasOps` reproduces both, so a frame that is
|
|
23
|
+
empty on one engine is not silently a different dtype on the other.
|
|
24
|
+
|
|
25
|
+
This is not a DataFrame implementation. It adapts execution semantics for
|
|
26
|
+
frames that already exist.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from typing import Any, Callable
|
|
32
|
+
|
|
33
|
+
__all__ = ["DaskOps", "DataFrameOps", "MetaSource", "PandasOps", "is_dask_frame"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_dask_frame(df: Any) -> bool:
|
|
37
|
+
"""True if ``df`` is a Dask DataFrame, without importing Dask unnecessarily.
|
|
38
|
+
|
|
39
|
+
Dask is an optional dependency: a caller working purely in pandas should not
|
|
40
|
+
pay an import for a question whose answer is no.
|
|
41
|
+
"""
|
|
42
|
+
module = type(df).__module__
|
|
43
|
+
if not module.startswith("dask"):
|
|
44
|
+
return False
|
|
45
|
+
try:
|
|
46
|
+
import dask.dataframe as dd
|
|
47
|
+
except ImportError: # pragma: no cover - only when dask is half-installed
|
|
48
|
+
return False
|
|
49
|
+
return isinstance(df, dd.DataFrame)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DataFrameOps:
|
|
53
|
+
"""Dispatch table for the operations that differ between engines.
|
|
54
|
+
|
|
55
|
+
Subclass :class:`DaskOps` or :class:`PandasOps` to add engine-specific
|
|
56
|
+
behaviour of your own -- custom aggregations, for instance -- and pass the
|
|
57
|
+
subclasses to :class:`~betterframe.BetterFrame`.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
#: Whether this implementation drives a Dask frame.
|
|
61
|
+
is_dask: bool
|
|
62
|
+
|
|
63
|
+
def apply(
|
|
64
|
+
self,
|
|
65
|
+
df: Any,
|
|
66
|
+
fn: Callable,
|
|
67
|
+
*args: Any,
|
|
68
|
+
meta: Callable | None = None,
|
|
69
|
+
**kwargs: Any,
|
|
70
|
+
) -> Any:
|
|
71
|
+
"""Apply a per-partition function.
|
|
72
|
+
|
|
73
|
+
``meta`` is a thunk rather than a value so Dask-only meta builders are
|
|
74
|
+
never invoked on a pandas frame.
|
|
75
|
+
"""
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
|
|
78
|
+
def index_names(self, df: Any) -> Any:
|
|
79
|
+
"""Names of the frame's index levels."""
|
|
80
|
+
raise NotImplementedError
|
|
81
|
+
|
|
82
|
+
def agg_kwargs(self, df: Any) -> dict[str, Any]:
|
|
83
|
+
"""Extra keyword arguments for ``groupby().agg()`` on this engine."""
|
|
84
|
+
raise NotImplementedError
|
|
85
|
+
|
|
86
|
+
def finalize(self, df: Any) -> Any:
|
|
87
|
+
"""Make the result concrete, if the engine has such a notion."""
|
|
88
|
+
raise NotImplementedError
|
|
89
|
+
|
|
90
|
+
def mutable(self, df: Any) -> Any:
|
|
91
|
+
"""A frame whose columns may be assigned without affecting the caller."""
|
|
92
|
+
raise NotImplementedError
|
|
93
|
+
|
|
94
|
+
def meta_source(self, df: Any) -> Any:
|
|
95
|
+
"""Something a Dask meta builder can read ``.columns`` and ``._meta`` from.
|
|
96
|
+
|
|
97
|
+
Lets meta builders written against Dask be reused unchanged on pandas
|
|
98
|
+
frames, rather than duplicated per engine.
|
|
99
|
+
"""
|
|
100
|
+
raise NotImplementedError
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class DaskOps(DataFrameOps):
|
|
104
|
+
"""Operations against a Dask DataFrame."""
|
|
105
|
+
|
|
106
|
+
is_dask = True
|
|
107
|
+
|
|
108
|
+
def apply(self, df, fn, *args, meta=None, **kwargs):
|
|
109
|
+
if meta is not None:
|
|
110
|
+
return df.map_partitions(fn, *args, meta=meta(), **kwargs)
|
|
111
|
+
return df.map_partitions(fn, *args, **kwargs)
|
|
112
|
+
|
|
113
|
+
def index_names(self, df):
|
|
114
|
+
return df.index._meta.names
|
|
115
|
+
|
|
116
|
+
def agg_kwargs(self, df):
|
|
117
|
+
# one output partition per input partition
|
|
118
|
+
return {"split_out": df.npartitions}
|
|
119
|
+
|
|
120
|
+
def finalize(self, df):
|
|
121
|
+
return df.persist()
|
|
122
|
+
|
|
123
|
+
def mutable(self, df):
|
|
124
|
+
# assignment on a Dask frame builds a new graph; nothing is aliased
|
|
125
|
+
return df
|
|
126
|
+
|
|
127
|
+
def meta_source(self, df):
|
|
128
|
+
return df
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class PandasOps(DataFrameOps):
|
|
132
|
+
"""Operations against a pandas DataFrame, emulating Dask's meta semantics."""
|
|
133
|
+
|
|
134
|
+
is_dask = False
|
|
135
|
+
|
|
136
|
+
def apply(self, df, fn, *args, meta=None, **kwargs):
|
|
137
|
+
out = fn(df, *args, **kwargs)
|
|
138
|
+
if not getattr(out, "empty", False):
|
|
139
|
+
return out
|
|
140
|
+
schema = (
|
|
141
|
+
meta() if meta is not None else self._infer_schema(df, fn, args, kwargs)
|
|
142
|
+
)
|
|
143
|
+
if schema is None:
|
|
144
|
+
return out
|
|
145
|
+
out = out.reindex(columns=schema.columns)
|
|
146
|
+
return out.astype(
|
|
147
|
+
{c: dt for c, dt in schema.dtypes.items() if c in out.columns}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _infer_schema(df, fn, args, kwargs):
|
|
152
|
+
"""What Dask would have inferred: run ``fn`` on a synthetic non-empty frame."""
|
|
153
|
+
try:
|
|
154
|
+
from dask.dataframe.utils import meta_nonempty
|
|
155
|
+
except ImportError:
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
return fn(meta_nonempty(df.iloc[:0]), *args, **kwargs).iloc[:0]
|
|
159
|
+
except Exception: # noqa: BLE001 - any failure means we cannot know the
|
|
160
|
+
# schema, and leaving the frame alone is safer than guessing at one
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
def index_names(self, df):
|
|
164
|
+
return df.index.names
|
|
165
|
+
|
|
166
|
+
def agg_kwargs(self, df):
|
|
167
|
+
# `split_out` is a Dask partitioning concern with no pandas equivalent
|
|
168
|
+
return {}
|
|
169
|
+
|
|
170
|
+
def finalize(self, df):
|
|
171
|
+
return df
|
|
172
|
+
|
|
173
|
+
def mutable(self, df):
|
|
174
|
+
# column assignment must not reach back into the caller's frame
|
|
175
|
+
return df.copy(deep=False)
|
|
176
|
+
|
|
177
|
+
def meta_source(self, df):
|
|
178
|
+
return MetaSource(df)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class MetaSource:
|
|
182
|
+
"""Adapts a pandas frame to the ``.columns`` / ``._meta`` shape Dask meta
|
|
183
|
+
builders expect, so one implementation of those builders serves both
|
|
184
|
+
engines rather than being duplicated per engine."""
|
|
185
|
+
|
|
186
|
+
__slots__ = ("_meta", "columns")
|
|
187
|
+
|
|
188
|
+
def __init__(self, df: Any) -> None:
|
|
189
|
+
self.columns = df.columns
|
|
190
|
+
self._meta = df.iloc[:0]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: betterframe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Write a dataframe pipeline once, run it on Dask or pandas
|
|
5
|
+
Project-URL: Homepage, https://github.com/izzet/betterframe
|
|
6
|
+
Project-URL: Repository, https://github.com/izzet/betterframe
|
|
7
|
+
Project-URL: Documentation, https://github.com/izzet/betterframe#readme
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/izzet/betterframe/issues
|
|
9
|
+
Author-email: Izzet Yildirim <izzetcyildirim@gmail.com>
|
|
10
|
+
Maintainer-email: Izzet Yildirim <izzetcyildirim@gmail.com>
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: dask,dataframe,meta,pandas,partitions,python
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: pandas>=1.5
|
|
26
|
+
Provides-Extra: dask
|
|
27
|
+
Requires-Dist: dask[dataframe]>=2023.1.0; extra == 'dask'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# BetterFrame
|
|
31
|
+
|
|
32
|
+
Write a dataframe pipeline once, run it on Dask or pandas.
|
|
33
|
+
|
|
34
|
+
Not a DataFrame implementation. BetterFrame adapts the *execution semantics*
|
|
35
|
+
that differ between the two engines — per-partition application, meta schemas,
|
|
36
|
+
aggregation keywords, persistence — so a pipeline that has to work on both does
|
|
37
|
+
not fill up with `if is_dask:` branches.
|
|
38
|
+
|
|
39
|
+
## Why
|
|
40
|
+
|
|
41
|
+
Dask is the right tool when data outgrows memory, and a needless tax when it
|
|
42
|
+
does not. A pipeline that wants both ends up either duplicated or littered with
|
|
43
|
+
engine checks. The differences are few but awkward:
|
|
44
|
+
|
|
45
|
+
| | Dask | pandas |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| apply a function per partition | `df.map_partitions(fn, meta=…)` | `fn(df)` |
|
|
48
|
+
| index level names | `df.index._meta.names` | `df.index.names` |
|
|
49
|
+
| `groupby().agg()` extras | `split_out=…` | — |
|
|
50
|
+
| make the result concrete | `df.persist()` | already is |
|
|
51
|
+
| column assignment | builds a graph | mutates in place |
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install betterframe # pandas only
|
|
57
|
+
pip install "betterframe[dask]" # to drive Dask frames as well
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Use
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from betterframe import BetterFrame
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def compute(records):
|
|
67
|
+
frame = BetterFrame(records).mutable() # records may be Dask or pandas
|
|
68
|
+
frame = frame.apply(normalise, meta=lambda: build_meta(frame.meta_source()))
|
|
69
|
+
result = frame.pipe(
|
|
70
|
+
lambda df, kw=frame.agg_kwargs(): df.groupby("key").agg({"scaled": "sum"}, **kw)
|
|
71
|
+
)
|
|
72
|
+
return result.finalize()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`BetterFrame` binds a frame to the operations its engine needs, so the frame
|
|
76
|
+
stops being an argument to every call. Methods that produce a frame return a
|
|
77
|
+
`BetterFrame`, so a pipeline chains; `finalize()` ends the chain and hands back
|
|
78
|
+
a native frame, and `native` reaches the underlying one at any point.
|
|
79
|
+
|
|
80
|
+
It is deliberately thin — it does **not** proxy the dataframe API. Real work
|
|
81
|
+
still happens on the frame itself, through `pipe()` or `native`. Wrapping exists
|
|
82
|
+
to answer engine questions, not to replace pandas.
|
|
83
|
+
|
|
84
|
+
The same function now runs on either engine, and there is exactly one place —
|
|
85
|
+
this package — that knows the difference.
|
|
86
|
+
|
|
87
|
+
## The part worth knowing about
|
|
88
|
+
|
|
89
|
+
Most of the surface is mechanical. The subtle part is Dask's `meta` handling,
|
|
90
|
+
because getting it wrong produces frames that differ **only in dtype, only for
|
|
91
|
+
empty inputs, and only sometimes**:
|
|
92
|
+
|
|
93
|
+
* Dask coerces *every* partition to a meta schema. A helper that short-circuits
|
|
94
|
+
on an empty frame and returns it unchanged still comes out with the populated
|
|
95
|
+
schema, because Dask repairs it afterwards.
|
|
96
|
+
* Where no explicit meta is given, Dask *infers* one by running the function
|
|
97
|
+
against a synthetic non-empty frame. So even without a meta, an empty
|
|
98
|
+
partition ends up with the schema the function would have produced had there
|
|
99
|
+
been rows.
|
|
100
|
+
|
|
101
|
+
pandas does neither. `PandasOps.apply` reproduces both, so an empty frame is
|
|
102
|
+
not silently a different dtype depending on which engine produced it. This is
|
|
103
|
+
the failure mode BetterFrame exists to prevent: it is invisible in every test
|
|
104
|
+
whose fixtures happen to be fully populated.
|
|
105
|
+
|
|
106
|
+
## Extending
|
|
107
|
+
|
|
108
|
+
Subclass `DaskOps` / `PandasOps` for engine-specific behaviour of your own —
|
|
109
|
+
custom aggregations, say — and hand the subclasses to `BetterFrame`:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from betterframe import BetterFrame, DaskOps, PandasOps
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class MyDaskOps(DaskOps):
|
|
116
|
+
def set_union(self):
|
|
117
|
+
return some_dask_aggregation()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class MyPandasOps(PandasOps):
|
|
121
|
+
def set_union(self):
|
|
122
|
+
return some_pandas_callable
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
frame = BetterFrame(records, dask_ops=MyDaskOps, pandas_ops=MyPandasOps)
|
|
126
|
+
frame.ops.set_union() # `ops` reaches engine questions that take no frame
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Domain-specific aggregations are deliberately left out of the core so the
|
|
130
|
+
package stays about engine semantics.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
uv sync --dev
|
|
136
|
+
uv run pytest
|
|
137
|
+
uv run ruff check .
|
|
138
|
+
uv run ruff format --check .
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
betterframe/__init__.py,sha256=jiqFSY3gtPNThOx3RuP25yT6Dr3-a0jHlkR7soUrDTk,1224
|
|
2
|
+
betterframe/frame.py,sha256=02YnP0yjS5I6t_P0kZQzcJtqOL-dU8Pd-J0iPUnYhiE,4027
|
|
3
|
+
betterframe/ops.py,sha256=cKb16ZSA17WHlj7qJ1P8fVszwFbJcpjFJWxVKxZEQmo,6554
|
|
4
|
+
betterframe-0.1.0.dist-info/METADATA,sha256=LskvQ_YAPP22ZzC_oRCgnbx6BvzxFGiFOxlXJOy89x8,5131
|
|
5
|
+
betterframe-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
6
|
+
betterframe-0.1.0.dist-info/licenses/LICENSE,sha256=JZMi5bggUJ2443hX6ES9BPrwuduIsBPvTM6yoN5BAm4,1071
|
|
7
|
+
betterframe-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Izzet Yildirim
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|