betterframe 0.1.0__tar.gz

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,62 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, develop]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - name: Install uv
20
+ uses: astral-sh/setup-uv@v4
21
+ with:
22
+ version: "latest"
23
+
24
+ - name: Set up Python ${{ matrix.python-version }}
25
+ run: uv python install ${{ matrix.python-version }}
26
+
27
+ - name: Install dependencies
28
+ run: |
29
+ uv sync --dev
30
+
31
+ - name: Lint with ruff
32
+ run: |
33
+ uv run ruff check .
34
+ uv run ruff format --check .
35
+
36
+ - name: Test with pytest
37
+ run: |
38
+ uv run pytest
39
+
40
+ build:
41
+ runs-on: ubuntu-latest
42
+ needs: test
43
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
44
+
45
+ steps:
46
+ - uses: actions/checkout@v4
47
+
48
+ - name: Install uv
49
+ uses: astral-sh/setup-uv@v4
50
+ with:
51
+ version: "latest"
52
+
53
+ - name: Set up Python
54
+ run: uv python install 3.9
55
+
56
+ - name: Build package
57
+ run: uv build
58
+
59
+ - name: Check package
60
+ run: |
61
+ uv tool install twine
62
+ uv tool run twine check dist/*
@@ -0,0 +1,42 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ id-token: write # For trusted publishing to PyPI
13
+
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Install uv
18
+ uses: astral-sh/setup-uv@v4
19
+ with:
20
+ version: "latest"
21
+
22
+ - name: Set up Python
23
+ run: uv python install 3.9
24
+
25
+ - name: Install dependencies
26
+ run: uv sync --dev
27
+
28
+ - name: Run tests
29
+ run: uv run pytest
30
+
31
+ - name: Check linting
32
+ run: |
33
+ uv run ruff check .
34
+ uv run ruff format --check .
35
+
36
+ - name: Build package
37
+ run: uv build
38
+
39
+ - name: Publish to PyPI
40
+ uses: pypa/gh-action-pypi-publish@release/v1
41
+ with:
42
+ print-hash: true
@@ -0,0 +1,16 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Local Python version pin (pyenv/uv/asdf)
13
+ .python-version
14
+
15
+ # uv
16
+ uv.lock.bak
@@ -0,0 +1,16 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.12.5
4
+ hooks:
5
+ - id: ruff
6
+ args: [--fix]
7
+ - id: ruff-format
8
+
9
+ - repo: local
10
+ hooks:
11
+ - id: pytest
12
+ name: pytest
13
+ entry: uv run pytest
14
+ language: system
15
+ pass_filenames: false
16
+ always_run: true
@@ -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.
@@ -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,114 @@
1
+ # BetterFrame
2
+
3
+ Write a dataframe pipeline once, run it on Dask or pandas.
4
+
5
+ Not a DataFrame implementation. BetterFrame adapts the *execution semantics*
6
+ that differ between the two engines — per-partition application, meta schemas,
7
+ aggregation keywords, persistence — so a pipeline that has to work on both does
8
+ not fill up with `if is_dask:` branches.
9
+
10
+ ## Why
11
+
12
+ Dask is the right tool when data outgrows memory, and a needless tax when it
13
+ does not. A pipeline that wants both ends up either duplicated or littered with
14
+ engine checks. The differences are few but awkward:
15
+
16
+ | | Dask | pandas |
17
+ |---|---|---|
18
+ | apply a function per partition | `df.map_partitions(fn, meta=…)` | `fn(df)` |
19
+ | index level names | `df.index._meta.names` | `df.index.names` |
20
+ | `groupby().agg()` extras | `split_out=…` | — |
21
+ | make the result concrete | `df.persist()` | already is |
22
+ | column assignment | builds a graph | mutates in place |
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install betterframe # pandas only
28
+ pip install "betterframe[dask]" # to drive Dask frames as well
29
+ ```
30
+
31
+ ## Use
32
+
33
+ ```python
34
+ from betterframe import BetterFrame
35
+
36
+
37
+ def compute(records):
38
+ frame = BetterFrame(records).mutable() # records may be Dask or pandas
39
+ frame = frame.apply(normalise, meta=lambda: build_meta(frame.meta_source()))
40
+ result = frame.pipe(
41
+ lambda df, kw=frame.agg_kwargs(): df.groupby("key").agg({"scaled": "sum"}, **kw)
42
+ )
43
+ return result.finalize()
44
+ ```
45
+
46
+ `BetterFrame` binds a frame to the operations its engine needs, so the frame
47
+ stops being an argument to every call. Methods that produce a frame return a
48
+ `BetterFrame`, so a pipeline chains; `finalize()` ends the chain and hands back
49
+ a native frame, and `native` reaches the underlying one at any point.
50
+
51
+ It is deliberately thin — it does **not** proxy the dataframe API. Real work
52
+ still happens on the frame itself, through `pipe()` or `native`. Wrapping exists
53
+ to answer engine questions, not to replace pandas.
54
+
55
+ The same function now runs on either engine, and there is exactly one place —
56
+ this package — that knows the difference.
57
+
58
+ ## The part worth knowing about
59
+
60
+ Most of the surface is mechanical. The subtle part is Dask's `meta` handling,
61
+ because getting it wrong produces frames that differ **only in dtype, only for
62
+ empty inputs, and only sometimes**:
63
+
64
+ * Dask coerces *every* partition to a meta schema. A helper that short-circuits
65
+ on an empty frame and returns it unchanged still comes out with the populated
66
+ schema, because Dask repairs it afterwards.
67
+ * Where no explicit meta is given, Dask *infers* one by running the function
68
+ against a synthetic non-empty frame. So even without a meta, an empty
69
+ partition ends up with the schema the function would have produced had there
70
+ been rows.
71
+
72
+ pandas does neither. `PandasOps.apply` reproduces both, so an empty frame is
73
+ not silently a different dtype depending on which engine produced it. This is
74
+ the failure mode BetterFrame exists to prevent: it is invisible in every test
75
+ whose fixtures happen to be fully populated.
76
+
77
+ ## Extending
78
+
79
+ Subclass `DaskOps` / `PandasOps` for engine-specific behaviour of your own —
80
+ custom aggregations, say — and hand the subclasses to `BetterFrame`:
81
+
82
+ ```python
83
+ from betterframe import BetterFrame, DaskOps, PandasOps
84
+
85
+
86
+ class MyDaskOps(DaskOps):
87
+ def set_union(self):
88
+ return some_dask_aggregation()
89
+
90
+
91
+ class MyPandasOps(PandasOps):
92
+ def set_union(self):
93
+ return some_pandas_callable
94
+
95
+
96
+ frame = BetterFrame(records, dask_ops=MyDaskOps, pandas_ops=MyPandasOps)
97
+ frame.ops.set_union() # `ops` reaches engine questions that take no frame
98
+ ```
99
+
100
+ Domain-specific aggregations are deliberately left out of the core so the
101
+ package stays about engine semantics.
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ uv sync --dev
107
+ uv run pytest
108
+ uv run ruff check .
109
+ uv run ruff format --check .
110
+ ```
111
+
112
+ ## License
113
+
114
+ MIT
@@ -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"
@@ -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__})"