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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.