shackleton 1.1.0__tar.gz → 2.0.1__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.
- shackleton-2.0.1/.github/workflows/release.yml +27 -0
- {shackleton-1.1.0 → shackleton-2.0.1}/.github/workflows/test.yml +3 -2
- {shackleton-1.1.0 → shackleton-2.0.1}/.gitignore +2 -12
- {shackleton-1.1.0 → shackleton-2.0.1}/PKG-INFO +60 -7
- {shackleton-1.1.0 → shackleton-2.0.1}/README.md +57 -4
- shackleton-2.0.1/coverage/report.md +6 -0
- shackleton-2.0.1/docs/release_notes.md +29 -0
- {shackleton-1.1.0 → shackleton-2.0.1}/pyproject.toml +1 -1
- shackleton-2.0.1/shackleton/__init__.py +13 -0
- {shackleton-1.1.0 → shackleton-2.0.1}/shackleton/core.py +197 -56
- {shackleton-1.1.0 → shackleton-2.0.1}/shackleton/tests/test_core.py +288 -161
- {shackleton-1.1.0 → shackleton-2.0.1}/uv.lock +14 -14
- shackleton-1.1.0/.github/workflows/twine_release.yml +0 -15
- shackleton-1.1.0/coverage/report.md +0 -6
- shackleton-1.1.0/docs/release_notes.md +0 -3
- shackleton-1.1.0/shackleton/__init__.py +0 -7
- {shackleton-1.1.0 → shackleton-2.0.1}/LICENSE +0 -0
- {shackleton-1.1.0 → shackleton-2.0.1}/PERF.md +0 -0
- {shackleton-1.1.0 → shackleton-2.0.1}/bench.py +0 -0
- {shackleton-1.1.0 → shackleton-2.0.1}/docs/api.md +0 -0
- {shackleton-1.1.0 → shackleton-2.0.1}/plans.md +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
gate:
|
|
10
|
+
# compatibility_test.yml here also requires the OS matrix to pass
|
|
11
|
+
uses: ./.github/workflows/test.yml
|
|
12
|
+
release:
|
|
13
|
+
needs: gate
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
permissions:
|
|
16
|
+
contents: write
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v7
|
|
19
|
+
with:
|
|
20
|
+
fetch-depth: 0
|
|
21
|
+
- uses: astral-sh/setup-uv@v10.3.0
|
|
22
|
+
- run: uv build && uv publish
|
|
23
|
+
env:
|
|
24
|
+
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
|
25
|
+
- run: gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --notes "$(git tag -l --format='%(contents)' "$GITHUB_REF_NAME")"
|
|
26
|
+
env:
|
|
27
|
+
GH_TOKEN: ${{ github.token }}
|
|
@@ -7,13 +7,14 @@ on:
|
|
|
7
7
|
push:
|
|
8
8
|
branches:
|
|
9
9
|
- main
|
|
10
|
+
workflow_call:
|
|
10
11
|
|
|
11
12
|
jobs:
|
|
12
13
|
test:
|
|
13
14
|
runs-on: ubuntu-latest
|
|
14
15
|
steps:
|
|
15
|
-
- uses: actions/checkout@
|
|
16
|
-
- uses: astral-sh/setup-uv@
|
|
16
|
+
- uses: actions/checkout@v7
|
|
17
|
+
- uses: astral-sh/setup-uv@v10.3.0
|
|
17
18
|
with:
|
|
18
19
|
python-version: '3.12'
|
|
19
20
|
enable-cache: true
|
|
@@ -68,19 +68,12 @@ instance/
|
|
|
68
68
|
# Scrapy stuff:
|
|
69
69
|
.scrapy
|
|
70
70
|
|
|
71
|
-
#
|
|
72
|
-
|
|
71
|
+
# Generated coverage HTML (report.md is committed, HTML is not)
|
|
72
|
+
coverage/html/
|
|
73
73
|
|
|
74
74
|
# PyBuilder
|
|
75
75
|
target/
|
|
76
76
|
|
|
77
|
-
# Jupyter Notebook
|
|
78
|
-
.ipynb_checkpoints
|
|
79
|
-
|
|
80
|
-
# IPython
|
|
81
|
-
profile_default/
|
|
82
|
-
ipython_config.py
|
|
83
|
-
|
|
84
77
|
# pyenv
|
|
85
78
|
.python-version
|
|
86
79
|
|
|
@@ -113,9 +106,6 @@ venv.bak/
|
|
|
113
106
|
# Rope project settings
|
|
114
107
|
.ropeproject
|
|
115
108
|
|
|
116
|
-
# mkdocs documentation
|
|
117
|
-
/site
|
|
118
|
-
|
|
119
109
|
# mypy
|
|
120
110
|
.mypy_cache/
|
|
121
111
|
.dmypy.json
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: shackleton
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.1
|
|
4
4
|
Summary: Polars-based persistent tabular store backed by Parquet or Arrow IPC files
|
|
5
5
|
Project-URL: Homepage, https://github.com/endremborza/shackleton
|
|
6
6
|
Author-email: Endre Márk Borza <endremborza@gmail.com>
|
|
@@ -13,7 +13,7 @@ License: Copyright 2023 Endre Márk Borza
|
|
|
13
13
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
14
14
|
License-File: LICENSE
|
|
15
15
|
Requires-Python: >=3.12
|
|
16
|
-
Requires-Dist: polars
|
|
16
|
+
Requires-Dist: polars>=2
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
|
|
19
19
|
# shackleton
|
|
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
|
|
23
23
|
Persistent tabular store backed by Parquet or Arrow IPC, built on [polars](https://pola.rs).
|
|
24
24
|
|
|
25
|
-
- **Partitioned writes** — by column value or
|
|
25
|
+
- **Partitioned writes** — by column value or hash bucket
|
|
26
26
|
- **Sorted merge** — maintain sorted order on extend via `id_col`
|
|
27
27
|
- **Lazy reads** — returns `pl.LazyFrame` so polars can push down predicates
|
|
28
28
|
- **Schema evolution** — diagonal concat handles mismatched columns transparently
|
|
@@ -91,7 +91,7 @@ df_eu = repo.get_partition_df({"region": "eu"})
|
|
|
91
91
|
## Hash partitioning
|
|
92
92
|
|
|
93
93
|
When partition values have unbounded cardinality, hash into fixed buckets instead.
|
|
94
|
-
Uses polars'
|
|
94
|
+
Uses polars' `Series.hash` — vectorised, GIL-free. That hash is not stable across polars versions while bucket assignment lives on disk, so before its first bucket-addressed operation (a write, `get_partition_*`, `purge_partition`) a repo samples stored rows and raises `HashDriftError` if they no longer hash to their bucket. `repo.rehash()` moves every row into its current bucket.
|
|
95
95
|
|
|
96
96
|
```python
|
|
97
97
|
from shackleton import HashPartitioner
|
|
@@ -110,10 +110,28 @@ df = repo.get_full_df()
|
|
|
110
110
|
|
|
111
111
|
---
|
|
112
112
|
|
|
113
|
+
## Deduplication with `dedup_cols`
|
|
114
|
+
|
|
115
|
+
When `dedup_cols` is set, each `extend` drops duplicate rows on those columns
|
|
116
|
+
after merging with existing data. With `id_col` the merge is sorted and the
|
|
117
|
+
existing (first) row wins; without `id_col` the surviving occurrence is arbitrary.
|
|
118
|
+
Not supported with `max_records > 0` (raises at construction time).
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
repo = TableRepo(Path("/tmp/events"), dedup_cols=["event_id"])
|
|
122
|
+
|
|
123
|
+
repo.extend(pl.DataFrame({"event_id": [1, 2, 3], "val": [10, 20, 30]}))
|
|
124
|
+
# Re-ingest with overlapping IDs — duplicates collapse to one row per event_id
|
|
125
|
+
repo.extend(pl.DataFrame({"event_id": [2, 4], "val": [99, 40]}))
|
|
126
|
+
|
|
127
|
+
repo.get_full_df().shape # (4, 2) — event_id 2 kept once
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
113
132
|
## Sorted merge with `id_col`
|
|
114
133
|
|
|
115
|
-
When `id_col` is set, each extend keeps the file sorted by
|
|
116
|
-
`replace_records` does an upsert: matching IDs are updated, new IDs appended.
|
|
134
|
+
When `id_col` is set, each extend keeps the file sorted by it (when `max_records == 0`) and `replace_records` does an upsert: matching IDs are updated, new IDs appended. `id_col` is a column or a list of columns, sorted in that order. On equal keys stored rows come before incoming ones, and within one `replace_records` batch the last row for a key wins.
|
|
117
135
|
|
|
118
136
|
```python
|
|
119
137
|
repo = TableRepo(Path("/tmp/prices"), id_col="ticker")
|
|
@@ -198,6 +216,41 @@ df = repo.get_full_df()
|
|
|
198
216
|
|
|
199
217
|
---
|
|
200
218
|
|
|
219
|
+
## Partition-level overwrite
|
|
220
|
+
|
|
221
|
+
`replace_partition` atomically clears and rewrites only the partitions covered
|
|
222
|
+
by the DataFrame. `purge_partition` deletes matching files without rewriting.
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
repo = TableRepo(Path("/tmp/events"), partition_cols=["date"])
|
|
226
|
+
repo.extend(pl.DataFrame({
|
|
227
|
+
"date": ["2024-01-01", "2024-01-01", "2024-01-02"],
|
|
228
|
+
"val": [1, 2, 3],
|
|
229
|
+
}))
|
|
230
|
+
|
|
231
|
+
# Replace just the 2024-01-01 partition with corrected data
|
|
232
|
+
repo.replace_partition(pl.DataFrame({
|
|
233
|
+
"date": ["2024-01-01"],
|
|
234
|
+
"val": [99],
|
|
235
|
+
}))
|
|
236
|
+
|
|
237
|
+
# Or wipe it without rewriting
|
|
238
|
+
repo.purge_partition({"date": "2024-01-01"})
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Compression
|
|
244
|
+
|
|
245
|
+
Control the codec and, for Parquet, the compression level.
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
repo = TableRepo(Path("/tmp/data"), compression="zstd", compression_level=3)
|
|
249
|
+
repo_ipc = TableRepo(Path("/tmp/fast"), ipc=True, compression="lz4")
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
201
254
|
## Arrow IPC format
|
|
202
255
|
|
|
203
256
|
Slightly faster reads for local workloads that don't need Parquet compatibility.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Persistent tabular store backed by Parquet or Arrow IPC, built on [polars](https://pola.rs).
|
|
6
6
|
|
|
7
|
-
- **Partitioned writes** — by column value or
|
|
7
|
+
- **Partitioned writes** — by column value or hash bucket
|
|
8
8
|
- **Sorted merge** — maintain sorted order on extend via `id_col`
|
|
9
9
|
- **Lazy reads** — returns `pl.LazyFrame` so polars can push down predicates
|
|
10
10
|
- **Schema evolution** — diagonal concat handles mismatched columns transparently
|
|
@@ -73,7 +73,7 @@ df_eu = repo.get_partition_df({"region": "eu"})
|
|
|
73
73
|
## Hash partitioning
|
|
74
74
|
|
|
75
75
|
When partition values have unbounded cardinality, hash into fixed buckets instead.
|
|
76
|
-
Uses polars'
|
|
76
|
+
Uses polars' `Series.hash` — vectorised, GIL-free. That hash is not stable across polars versions while bucket assignment lives on disk, so before its first bucket-addressed operation (a write, `get_partition_*`, `purge_partition`) a repo samples stored rows and raises `HashDriftError` if they no longer hash to their bucket. `repo.rehash()` moves every row into its current bucket.
|
|
77
77
|
|
|
78
78
|
```python
|
|
79
79
|
from shackleton import HashPartitioner
|
|
@@ -92,10 +92,28 @@ df = repo.get_full_df()
|
|
|
92
92
|
|
|
93
93
|
---
|
|
94
94
|
|
|
95
|
+
## Deduplication with `dedup_cols`
|
|
96
|
+
|
|
97
|
+
When `dedup_cols` is set, each `extend` drops duplicate rows on those columns
|
|
98
|
+
after merging with existing data. With `id_col` the merge is sorted and the
|
|
99
|
+
existing (first) row wins; without `id_col` the surviving occurrence is arbitrary.
|
|
100
|
+
Not supported with `max_records > 0` (raises at construction time).
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
repo = TableRepo(Path("/tmp/events"), dedup_cols=["event_id"])
|
|
104
|
+
|
|
105
|
+
repo.extend(pl.DataFrame({"event_id": [1, 2, 3], "val": [10, 20, 30]}))
|
|
106
|
+
# Re-ingest with overlapping IDs — duplicates collapse to one row per event_id
|
|
107
|
+
repo.extend(pl.DataFrame({"event_id": [2, 4], "val": [99, 40]}))
|
|
108
|
+
|
|
109
|
+
repo.get_full_df().shape # (4, 2) — event_id 2 kept once
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
95
114
|
## Sorted merge with `id_col`
|
|
96
115
|
|
|
97
|
-
When `id_col` is set, each extend keeps the file sorted by
|
|
98
|
-
`replace_records` does an upsert: matching IDs are updated, new IDs appended.
|
|
116
|
+
When `id_col` is set, each extend keeps the file sorted by it (when `max_records == 0`) and `replace_records` does an upsert: matching IDs are updated, new IDs appended. `id_col` is a column or a list of columns, sorted in that order. On equal keys stored rows come before incoming ones, and within one `replace_records` batch the last row for a key wins.
|
|
99
117
|
|
|
100
118
|
```python
|
|
101
119
|
repo = TableRepo(Path("/tmp/prices"), id_col="ticker")
|
|
@@ -180,6 +198,41 @@ df = repo.get_full_df()
|
|
|
180
198
|
|
|
181
199
|
---
|
|
182
200
|
|
|
201
|
+
## Partition-level overwrite
|
|
202
|
+
|
|
203
|
+
`replace_partition` atomically clears and rewrites only the partitions covered
|
|
204
|
+
by the DataFrame. `purge_partition` deletes matching files without rewriting.
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
repo = TableRepo(Path("/tmp/events"), partition_cols=["date"])
|
|
208
|
+
repo.extend(pl.DataFrame({
|
|
209
|
+
"date": ["2024-01-01", "2024-01-01", "2024-01-02"],
|
|
210
|
+
"val": [1, 2, 3],
|
|
211
|
+
}))
|
|
212
|
+
|
|
213
|
+
# Replace just the 2024-01-01 partition with corrected data
|
|
214
|
+
repo.replace_partition(pl.DataFrame({
|
|
215
|
+
"date": ["2024-01-01"],
|
|
216
|
+
"val": [99],
|
|
217
|
+
}))
|
|
218
|
+
|
|
219
|
+
# Or wipe it without rewriting
|
|
220
|
+
repo.purge_partition({"date": "2024-01-01"})
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Compression
|
|
226
|
+
|
|
227
|
+
Control the codec and, for Parquet, the compression level.
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
repo = TableRepo(Path("/tmp/data"), compression="zstd", compression_level=3)
|
|
231
|
+
repo_ipc = TableRepo(Path("/tmp/fast"), ipc=True, compression="lz4")
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
183
236
|
## Arrow IPC format
|
|
184
237
|
|
|
185
238
|
Slightly faster reads for local workloads that don't need Parquet compatibility.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
| Name | Stmts | Miss | Cover |
|
|
2
|
+
|------------------------------- | -------: | -------: | -------: |
|
|
3
|
+
| shackleton/\_\_init\_\_.py | 3 | 0 | 100% |
|
|
4
|
+
| shackleton/core.py | 289 | 0 | 100% |
|
|
5
|
+
| shackleton/tests/test\_core.py | 349 | 0 | 100% |
|
|
6
|
+
| **TOTAL** | **641** | **0** | **100%** |
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
## v2.0.1
|
|
2
|
+
|
|
3
|
+
- Fixed composite-key merges to be order-exact: stored rows now reliably come before incoming ones on equal keys, where polars' streaming engine could previously reorder ties.
|
|
4
|
+
- Fixed `replace_records` to work against a partition with no stored data yet.
|
|
5
|
+
- Writes are now atomic: data is staged to a temp file and moved into place with `os.replace`, so a crash mid-merge can no longer leave a partially-written file.
|
|
6
|
+
- Fixed partitions to be flagged sorted on read only when they are a single file, since appended parts are unsorted and were previously mis-flagged.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## v2.0.0
|
|
11
|
+
|
|
12
|
+
- **Breaking:** requires polars ≥2; existing `HashPartitioner` stores built under older polars versions will raise `HashDriftError` on their next bucket-addressed operation until migrated.
|
|
13
|
+
- Detects hash bucket drift before writes, `get_partition_*` and `purge_partition`, and adds `TableRepo.rehash()` to rebuild a store under the installed polars' hashing; full reads are unaffected and skip the check.
|
|
14
|
+
- `id_col` now accepts a list of columns for composite-key merges and dedup.
|
|
15
|
+
- Merges are now order-exact: on equal keys stored rows precede incoming ones, so `extend` with `dedup_cols` keeps the stored row and `replace_records` keeps the batch's last row instead of an arbitrary one.
|
|
16
|
+
- Writes are now staged to a temp file and atomically renamed into place, and merges with existing data stream instead of collecting fully in memory.
|
|
17
|
+
- `paths` are now returned sorted, so reads are deterministic.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## v1.2.0
|
|
22
|
+
|
|
23
|
+
deduped
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## v1.1.0
|
|
28
|
+
|
|
29
|
+
realign, simplify
|
|
@@ -6,7 +6,7 @@ authors = [{name = "Endre Márk Borza", email = "endremborza@gmail.com"}]
|
|
|
6
6
|
license = {file = "LICENSE"}
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
requires-python = ">=3.12"
|
|
9
|
-
dependencies = ["polars"]
|
|
9
|
+
dependencies = ["polars>=2"]
|
|
10
10
|
|
|
11
11
|
[project.urls]
|
|
12
12
|
Homepage = "https://github.com/endremborza/shackleton"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Polars-based persistent tabular store backed by Parquet or Arrow IPC files."""
|
|
2
|
+
|
|
3
|
+
from .core import ( # noqa: F401
|
|
4
|
+
DfBatchWriter,
|
|
5
|
+
HashDriftError,
|
|
6
|
+
HashPartitioner,
|
|
7
|
+
RecordWriter,
|
|
8
|
+
TableRepo,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
TableShack = TableRepo # backward-compat alias
|
|
12
|
+
|
|
13
|
+
__version__ = "2.0.1"
|