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.
@@ -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@v4
16
- - uses: astral-sh/setup-uv@v5
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
- # Sphinx documentation
72
- docs/_build/
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: shackleton
3
- Version: 1.1.0
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 xxhash bucket
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' native xxhash — vectorised, GIL-free.
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 that column and
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 xxhash bucket
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' native xxhash — vectorised, GIL-free.
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 that column and
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"