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.
- betterframe-0.1.0/.github/workflows/ci.yaml +62 -0
- betterframe-0.1.0/.github/workflows/release.yaml +42 -0
- betterframe-0.1.0/.gitignore +16 -0
- betterframe-0.1.0/.pre-commit-config.yaml +16 -0
- betterframe-0.1.0/LICENSE +21 -0
- betterframe-0.1.0/PKG-INFO +143 -0
- betterframe-0.1.0/README.md +114 -0
- betterframe-0.1.0/betterframe/__init__.py +38 -0
- betterframe-0.1.0/betterframe/frame.py +113 -0
- betterframe-0.1.0/betterframe/ops.py +190 -0
- betterframe-0.1.0/pyproject.toml +45 -0
- betterframe-0.1.0/tests/test_ops.py +330 -0
- betterframe-0.1.0/uv.lock +1392 -0
|
@@ -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
|
+
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__})"
|