shackleton 1.2.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.2.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
@@ -113,15 +113,15 @@ df = repo.get_full_df()
113
113
  ## Deduplication with `dedup_cols`
114
114
 
115
115
  When `dedup_cols` is set, each `extend` drops duplicate rows on those columns
116
- after merging with existing data — only the first occurrence is kept. Combine
117
- with `id_col` for sorted-merge dedup; use alone for unordered dedup.
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
118
  Not supported with `max_records > 0` (raises at construction time).
119
119
 
120
120
  ```python
121
121
  repo = TableRepo(Path("/tmp/events"), dedup_cols=["event_id"])
122
122
 
123
123
  repo.extend(pl.DataFrame({"event_id": [1, 2, 3], "val": [10, 20, 30]}))
124
- # Re-ingest with overlapping IDs — existing rows are kept, new ones appended
124
+ # Re-ingest with overlapping IDs — duplicates collapse to one row per event_id
125
125
  repo.extend(pl.DataFrame({"event_id": [2, 4], "val": [99, 40]}))
126
126
 
127
127
  repo.get_full_df().shape # (4, 2) — event_id 2 kept once
@@ -131,9 +131,7 @@ repo.get_full_df().shape # (4, 2) — event_id 2 kept once
131
131
 
132
132
  ## Sorted merge with `id_col`
133
133
 
134
- When `id_col` is set, each extend keeps the file sorted by that column
135
- (when `max_records == 0`) and `replace_records` does an upsert: matching IDs
136
- 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.
137
135
 
138
136
  ```python
139
137
  repo = TableRepo(Path("/tmp/prices"), id_col="ticker")
@@ -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
@@ -95,15 +95,15 @@ df = repo.get_full_df()
95
95
  ## Deduplication with `dedup_cols`
96
96
 
97
97
  When `dedup_cols` is set, each `extend` drops duplicate rows on those columns
98
- after merging with existing data — only the first occurrence is kept. Combine
99
- with `id_col` for sorted-merge dedup; use alone for unordered dedup.
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
100
  Not supported with `max_records > 0` (raises at construction time).
101
101
 
102
102
  ```python
103
103
  repo = TableRepo(Path("/tmp/events"), dedup_cols=["event_id"])
104
104
 
105
105
  repo.extend(pl.DataFrame({"event_id": [1, 2, 3], "val": [10, 20, 30]}))
106
- # Re-ingest with overlapping IDs — existing rows are kept, new ones appended
106
+ # Re-ingest with overlapping IDs — duplicates collapse to one row per event_id
107
107
  repo.extend(pl.DataFrame({"event_id": [2, 4], "val": [99, 40]}))
108
108
 
109
109
  repo.get_full_df().shape # (4, 2) — event_id 2 kept once
@@ -113,9 +113,7 @@ repo.get_full_df().shape # (4, 2) — event_id 2 kept once
113
113
 
114
114
  ## Sorted merge with `id_col`
115
115
 
116
- When `id_col` is set, each extend keeps the file sorted by that column
117
- (when `max_records == 0`) and `replace_records` does an upsert: matching IDs
118
- 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.
119
117
 
120
118
  ```python
121
119
  repo = TableRepo(Path("/tmp/prices"), id_col="ticker")
@@ -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"
@@ -1,8 +1,10 @@
1
1
  import contextlib
2
2
  import fcntl
3
+ import os
4
+ import shutil
3
5
  import threading
4
6
  from concurrent.futures import ThreadPoolExecutor
5
- from dataclasses import dataclass, field
7
+ from dataclasses import dataclass, field, replace
6
8
  from functools import cached_property
7
9
  from pathlib import Path
8
10
  from typing import Any, Callable, Iterable, TypeVar
@@ -12,6 +14,13 @@ import polars as pl
12
14
  T = TypeVar("T")
13
15
 
14
16
  FILE_NAME = "data"
17
+ HASH_CHECK_DIRS = 8
18
+ HASH_CHECK_ROWS = 64
19
+
20
+
21
+ class HashDriftError(Exception):
22
+ """Stored rows sit in buckets the installed polars no longer hashes them to."""
23
+
15
24
 
16
25
  _PATH_LOCKS: dict[str, threading.Lock] = {}
17
26
  _LOCKS_LOCK = threading.Lock()
@@ -44,7 +53,11 @@ def _dir_lock(path_str: str):
44
53
  class HashPartitioner:
45
54
  """Partition rows by hashing a column into `num_groups` zero-padded buckets.
46
55
 
47
- Uses polars' native xxhash for vectorised, GIL-free partitioning.
56
+ Uses polars' ``Series.hash`` for vectorised, GIL-free partitioning. That hash
57
+ is not stable across polars versions while bucket assignment is persisted on
58
+ disk, so ``TableRepo`` samples stored rows before its first bucket-addressed
59
+ operation and raises ``HashDriftError`` if they no longer hash to their bucket;
60
+ ``TableRepo.rehash`` rewrites the layout.
48
61
  The computed column (``key``) is stripped before writing to files.
49
62
  """
50
63
 
@@ -75,13 +88,15 @@ class TableRepo:
75
88
 
76
89
  Args:
77
90
  root_path: Root directory for all data files.
78
- id_col: Column used as a unique row identifier. Required for
79
- ``replace_records``; enables sorted merge on ``extend`` when
80
- ``max_records == 0`` (no effect on the per-file append path).
91
+ id_col: Column, or columns in sort order, identifying a row. Required
92
+ for ``replace_records``; enables sorted merge on ``extend`` when
93
+ ``max_records == 0`` (no effect on the per-file append path). On
94
+ equal keys, stored rows precede incoming ones and a batch keeps
95
+ its own order: ``replace_records`` keeps the batch's last row.
81
96
  dedup_cols: Columns forming a composite dedup key. When set, ``extend``
82
- drops duplicates after merging with existing data. Only effective
83
- when ``max_records == 0``; raises ``ValueError`` otherwise.
84
- ``replace_records`` still requires a scalar ``id_col``.
97
+ drops duplicates after merging with existing data, keeping the
98
+ stored row when ``id_col`` is set. Only effective when
99
+ ``max_records == 0``; raises ``ValueError`` otherwise.
85
100
  partition_cols: Column name(s) to partition by, or a
86
101
  ``HashPartitioner`` for hash-based bucketing. Partition values
87
102
  are encoded as hive-style directories (``col=val/``).
@@ -96,13 +111,14 @@ class TableRepo:
96
111
  """
97
112
 
98
113
  root_path: Path
99
- id_col: str | None = None
114
+ id_col: str | list[str] | None = None
100
115
  dedup_cols: list[str] | None = None
101
116
  partition_cols: list[str] | HashPartitioner = field(default_factory=list)
102
117
  max_records: int = 0
103
118
  ipc: bool = False
104
119
  compression: str | None = None
105
120
  compression_level: int | None = None
121
+ _buckets_verified: bool = field(default=False, init=False, repr=False)
106
122
 
107
123
  def __post_init__(self) -> None:
108
124
  if self.dedup_cols and self.max_records:
@@ -112,6 +128,13 @@ class TableRepo:
112
128
  def extension(self) -> str:
113
129
  return ".arrow" if self.ipc else ".parquet"
114
130
 
131
+ @cached_property
132
+ def id_cols(self) -> list[str]:
133
+ """``id_col`` as a list; empty when unset."""
134
+ if self.id_col is None:
135
+ return []
136
+ return [self.id_col] if isinstance(self.id_col, str) else list(self.id_col)
137
+
115
138
  # --- write ---
116
139
 
117
140
  def extend(self, df: pl.DataFrame) -> None:
@@ -124,7 +147,7 @@ class TableRepo:
124
147
  Requires ``id_col`` to be set. For multi-file partitions
125
148
  (``max_records > 0``) performs a full read-merge-rewrite.
126
149
  """
127
- assert self.id_col is not None, "replace_records requires id_col"
150
+ assert self.id_cols, "replace_records requires id_col"
128
151
  self._for_each_partition(df, self._replace_dir)
129
152
 
130
153
  def replace_all(self, df: pl.DataFrame) -> None:
@@ -151,14 +174,31 @@ class TableRepo:
151
174
  for p in self.paths:
152
175
  p.unlink()
153
176
 
177
+ def rehash(self) -> None:
178
+ """Move every row of a ``HashPartitioner`` store into the bucket the
179
+ installed polars hashes it to — the fix for ``HashDriftError``.
180
+
181
+ Builds the new layout in a sibling ``<root>.rehash`` directory and swaps
182
+ it in. Holds the whole store in memory; assumes no concurrent writers.
183
+ """
184
+ if self._hasher is None:
185
+ raise ValueError("rehash requires a HashPartitioner")
186
+ staging = self.root_path.with_name(self.root_path.name + ".rehash")
187
+ if staging.exists():
188
+ raise FileExistsError(f"{staging} left by an interrupted rehash")
189
+ df = self.get_full_df()
190
+ if df.is_empty():
191
+ return
192
+ replace(self, root_path=staging).extend(df)
193
+ shutil.rmtree(self.root_path)
194
+ staging.rename(self.root_path)
195
+ self._buckets_verified = True
196
+
154
197
  # --- read ---
155
198
 
156
199
  def get_full_lf(self) -> pl.LazyFrame:
157
200
  """Lazy frame over all data in the store."""
158
- parts = list(self.paths)
159
- if not parts:
160
- return pl.LazyFrame()
161
- return pl.concat(map(self._lazy_read, parts), how="diagonal")
201
+ return self._concat_lf(self.paths)
162
202
 
163
203
  def get_full_df(self) -> pl.DataFrame:
164
204
  """Collect all data into a single DataFrame."""
@@ -171,10 +211,7 @@ class TableRepo:
171
211
  For ``HashPartitioner`` repos, use ``repo.partition_cols.key``
172
212
  as the dict key with the zero-padded bucket string as the value.
173
213
  """
174
- paths = list(self._partition_paths(partitions))
175
- if not paths:
176
- return pl.LazyFrame()
177
- return pl.concat(map(self._lazy_read, paths), how="diagonal")
214
+ return self._concat_lf(self._partition_paths(partitions))
178
215
 
179
216
  def get_partition_df(self, partitions: dict[str, Any]) -> pl.DataFrame:
180
217
  """Collect partitions matching ``partitions`` dict into a DataFrame."""
@@ -183,9 +220,9 @@ class TableRepo:
183
220
  # --- iteration ---
184
221
 
185
222
  @property
186
- def paths(self) -> Iterable[Path]:
187
- """All data file paths in the store."""
188
- return self.root_path.glob("**/*" + self.extension)
223
+ def paths(self) -> list[Path]:
224
+ """All data file paths in the store, sorted so reads are deterministic."""
225
+ return sorted(self.root_path.glob("**/*" + self.extension))
189
226
 
190
227
  @property
191
228
  def n_files(self) -> int:
@@ -248,8 +285,7 @@ class TableRepo:
248
285
 
249
286
  def _apply(d: Path) -> T:
250
287
  files = sorted(d.glob("*" + self.extension))
251
- lf = pl.concat([self._lazy_read(f) for f in files], how="diagonal")
252
- return fn(lf.collect())
288
+ return fn(self._concat_lf(files).collect())
253
289
 
254
290
  if workers is None:
255
291
  return [_apply(d) for d in dirs]
@@ -269,9 +305,7 @@ class TableRepo:
269
305
  files = sorted(d.glob("*" + self.extension))
270
306
  if len(files) <= 1:
271
307
  continue
272
- df = pl.concat(
273
- [self._lazy_read(f) for f in files], how="diagonal"
274
- ).collect()
308
+ df = self._concat_lf(files).collect()
275
309
  for f in files:
276
310
  f.unlink()
277
311
  for idx, offset in enumerate(range(0, max(len(df), 1), target_rows)):
@@ -282,47 +316,73 @@ class TableRepo:
282
316
 
283
317
  # --- internals ---
284
318
 
319
+ def _concat_lf(self, paths: Iterable[Path]) -> pl.LazyFrame:
320
+ paths = list(paths)
321
+ if not paths:
322
+ return pl.LazyFrame()
323
+ return pl.concat(map(self._lazy_read, paths), how="diagonal")
324
+
285
325
  def _lazy_read(self, path: Path) -> pl.LazyFrame:
286
326
  lf = (
287
327
  pl.scan_ipc(path)
288
328
  if self.ipc
289
329
  else pl.scan_parquet(path, hive_partitioning=False)
290
330
  )
291
- return lf.set_sorted(self.id_col) if self.id_col else lf
331
+ # only single-file partitions stay sorted; the append path does not sort
332
+ return (
333
+ lf.set_sorted(self.id_cols) if self.id_cols and not self.max_records else lf
334
+ )
292
335
 
293
- def _write(self, df: pl.DataFrame, path: Path) -> None:
336
+ def _write(self, data: pl.DataFrame | pl.LazyFrame, path: Path) -> None:
337
+ """Write atomically; a LazyFrame (a merge with stored data) is streamed."""
294
338
  path.parent.mkdir(parents=True, exist_ok=True)
339
+ staged = path.with_name(path.name + ".tmp")
295
340
  kw: dict = {}
296
341
  if self.compression:
297
342
  kw["compression"] = self.compression
298
- if self.ipc:
299
- df.write_ipc(path, **kw)
343
+ if not self.ipc and self.compression_level is not None:
344
+ kw["compression_level"] = self.compression_level
345
+ if isinstance(data, pl.LazyFrame):
346
+ (data.sink_ipc if self.ipc else data.sink_parquet)(staged, **kw)
300
347
  else:
301
- if self.compression_level is not None:
302
- kw["compression_level"] = self.compression_level
303
- df.write_parquet(path, **kw)
348
+ (data.write_ipc if self.ipc else data.write_parquet)(staged, **kw)
349
+ os.replace(staged, path)
350
+
351
+ def _sort(self, lf: pl.LazyFrame) -> pl.LazyFrame:
352
+ return lf.sort(self.id_cols, maintain_order=True)
304
353
 
305
354
  def _merge_extend(self, new: pl.LazyFrame, old: pl.LazyFrame) -> pl.LazyFrame:
306
- if self.id_col:
307
- out = old.merge_sorted(new.sort(self.id_col), key=self.id_col)
355
+ if self.id_cols:
356
+ out = old.merge_sorted(
357
+ self._sort(new), key=self.id_cols, maintain_order=True
358
+ )
308
359
  else:
309
360
  out = pl.concat([old, new], how="diagonal")
310
361
  if self.dedup_cols:
311
- # when id_col is set, merge_sorted makes dupes adjacent; keep="first"
312
- # with maintain_order=True then runs in O(n) and preserves sort
362
+ # an ordered merge puts stored rows before incoming ones on equal keys,
363
+ # so keep="first" keeps the stored row and maintain_order the sort
313
364
  out = out.unique(
314
365
  subset=self.dedup_cols,
315
- keep="first" if self.id_col else "any",
316
- maintain_order=bool(self.id_col),
366
+ keep="first" if self.id_cols else "any",
367
+ maintain_order=bool(self.id_cols),
317
368
  )
318
369
  return out
319
370
 
320
- def _merge_replace(self, new: pl.LazyFrame, old: pl.LazyFrame) -> pl.LazyFrame:
321
- return (
322
- old.merge_sorted(new.sort(self.id_col), key=self.id_col)
323
- .unique(subset=self.id_col, keep="last")
324
- .sort(self.id_col)
371
+ def _merge_replace(
372
+ self, new: pl.LazyFrame, old: pl.LazyFrame | None
373
+ ) -> pl.LazyFrame:
374
+ merged = (
375
+ self._sort(new)
376
+ if old is None
377
+ else old.merge_sorted(
378
+ self._sort(new), key=self.id_cols, maintain_order=True
379
+ )
325
380
  )
381
+ # sorted, so a key's rows are adjacent: keep the last row of each run
382
+ last_of_run = pl.any_horizontal(
383
+ pl.col(c).ne_missing(pl.col(c).shift(-1)) for c in self.id_cols
384
+ )
385
+ return merged.filter(last_of_run)
326
386
 
327
387
  def _for_each_partition(self, df: pl.DataFrame, handler: Callable) -> None:
328
388
  keys = self._part_keys
@@ -330,18 +390,37 @@ class TableRepo:
330
390
  handler(df, self.root_path)
331
391
  return
332
392
 
333
- is_hash = isinstance(self.partition_cols, HashPartitioner)
334
- if is_hash:
335
- df = self.partition_cols.add_to_df(df)
393
+ hasher = self._hasher
394
+ if hasher is not None:
395
+ self._verify_buckets(hasher)
396
+ df = hasher.add_to_df(df)
336
397
 
337
398
  for group_vals, group_df in df.group_by(keys):
338
399
  group_vals = group_vals if isinstance(group_vals, tuple) else (group_vals,)
339
400
  hive_parts = [f"{k}={v}" for k, v in zip(keys, group_vals)]
340
401
  part_dir = Path(self.root_path, *hive_parts)
341
- if is_hash:
402
+ if hasher is not None:
342
403
  group_df = group_df.drop(keys)
343
404
  handler(group_df, part_dir)
344
405
 
406
+ def _verify_buckets(self, hasher: HashPartitioner) -> None:
407
+ if self._buckets_verified:
408
+ return
409
+ for d in self._partition_dirs[:HASH_CHECK_DIRS]:
410
+ bucket = d.name.split("=", 1)[1]
411
+ sample = (
412
+ self._lazy_read(next(d.glob("*" + self.extension)))
413
+ .select(hasher.col)
414
+ .head(HASH_CHECK_ROWS)
415
+ .collect()
416
+ )
417
+ if (hasher.add_to_df(sample)[hasher.key] != bucket).any():
418
+ raise HashDriftError(
419
+ f"{self.root_path}: rows of bucket {bucket} hash elsewhere under "
420
+ f"polars {pl.__version__}; rewrite with TableRepo.rehash()"
421
+ )
422
+ self._buckets_verified = True
423
+
345
424
  def _extend_dir(self, df: pl.DataFrame, root: Path) -> None:
346
425
  with _dir_lock(str(root)):
347
426
  if self.max_records > 0:
@@ -353,11 +432,13 @@ class TableRepo:
353
432
  out = df
354
433
  if self.dedup_cols:
355
434
  out = out.unique(subset=self.dedup_cols, maintain_order=False)
356
- if self.id_col:
357
- out = out.sort(self.id_col)
435
+ if self.id_cols:
436
+ out = out.sort(self.id_cols, maintain_order=True)
437
+ self._write(out, path)
358
438
  else:
359
- out = self._merge_extend(df.lazy(), self._lazy_read(path)).collect()
360
- self._write(out, path)
439
+ self._write(
440
+ self._merge_extend(df.lazy(), self._lazy_read(path)), path
441
+ )
361
442
 
362
443
  def _purge_dir(self, root: Path) -> None:
363
444
  with _dir_lock(str(root)):
@@ -371,21 +452,19 @@ class TableRepo:
371
452
  def _replace_dir(self, df: pl.DataFrame, root: Path) -> None:
372
453
  with _dir_lock(str(root)):
373
454
  existing = sorted(root.glob("*" + self.extension))
374
- if not existing:
375
- self._write(df.sort(self.id_col), self._single_path(root))
376
- return
455
+ old = None
456
+ if existing:
457
+ old = self._concat_lf(existing)
458
+ if self.max_records > 0:
459
+ old = self._sort(old)
460
+ out = self._merge_replace(df.lazy(), old)
377
461
  if self.max_records > 0:
378
- old = pl.concat(
379
- [self._lazy_read(p) for p in existing], how="diagonal"
380
- ).collect()
381
- out = self._merge_replace(df.lazy(), old.lazy()).collect()
462
+ out = out.collect()
382
463
  for p in existing:
383
464
  p.unlink()
384
465
  self._append_parts(out, root, [])
385
466
  else:
386
- path = existing[0]
387
- out = self._merge_replace(df.lazy(), self._lazy_read(path)).collect()
388
- self._write(out, path)
467
+ self._write(out, self._single_path(root))
389
468
 
390
469
  def _append_parts(self, df: pl.DataFrame, root: Path, existing: list[Path]) -> None:
391
470
  if existing:
@@ -406,6 +485,8 @@ class TableRepo:
406
485
  keys = self._part_keys
407
486
  if not keys:
408
487
  return []
488
+ if self._hasher is not None:
489
+ self._verify_buckets(self._hasher)
409
490
  g = "/".join(f"{c}={partitions.get(c, '*')}" for c in keys)
410
491
  return self.root_path.glob(f"{g}/*{self.extension}")
411
492
 
@@ -423,12 +504,17 @@ class TableRepo:
423
504
  result.append(d)
424
505
  return result
425
506
 
507
+ @cached_property
508
+ def _hasher(self) -> HashPartitioner | None:
509
+ pc = self.partition_cols
510
+ return pc if isinstance(pc, HashPartitioner) else None
511
+
426
512
  @cached_property
427
513
  def _part_keys(self) -> list[str]:
428
514
  if not self.partition_cols:
429
515
  return []
430
- if isinstance(self.partition_cols, HashPartitioner):
431
- return [self.partition_cols.key]
516
+ if self._hasher is not None:
517
+ return [self._hasher.key]
432
518
  return list(self.partition_cols)
433
519
 
434
520
 
@@ -5,7 +5,7 @@ from base64 import b16encode
5
5
  import polars as pl
6
6
  import pytest
7
7
 
8
- from shackleton import HashPartitioner, TableRepo
8
+ from shackleton import HashDriftError, HashPartitioner, TableRepo
9
9
 
10
10
 
11
11
  def make_df(n: int, seed: int = 0) -> pl.DataFrame:
@@ -173,7 +173,16 @@ def test_replace_records_dedup(tmp_path):
173
173
  repo.replace_records(pl.DataFrame({"id": [2, 2], "val": [20, 21]}))
174
174
  result = repo.get_full_df().sort("id")
175
175
  assert result.shape[0] == 3
176
- assert result.filter(pl.col("id") == 2)["val"][0] in (20, 21)
176
+ assert result.filter(pl.col("id") == 2)["val"][0] == 21 # batch's last row wins
177
+
178
+
179
+ def test_replace_records_over_duplicate_ids(tmp_path):
180
+ repo = TableRepo(tmp_path / "data", id_col="id")
181
+ ids = list(range(10))
182
+ repo.extend(pl.DataFrame({"id": ids, "val": 1}))
183
+ repo.extend(pl.DataFrame({"id": ids, "val": 2}))
184
+ repo.replace_records(pl.DataFrame({"id": ids, "val": 3}))
185
+ assert repo.get_full_df()["val"].to_list() == [3] * 10
177
186
 
178
187
 
179
188
  def test_replace_records_with_max_records(tmp_path):
@@ -187,12 +196,44 @@ def test_replace_records_with_max_records(tmp_path):
187
196
  assert repo.n_files == 2 # 4 rows at max_records=2
188
197
 
189
198
 
199
+ def test_replace_records_max_records_unsorted_parts(tmp_path):
200
+ repo = TableRepo(tmp_path / "data", id_col="id", max_records=2)
201
+ repo.extend(pl.DataFrame({"id": [3, 1, 2], "val": [30, 10, 20]}))
202
+ repo.replace_records(pl.DataFrame({"id": [1], "val": [100]}))
203
+ result = repo.get_full_df()
204
+ assert result["id"].to_list() == [1, 2, 3]
205
+ assert result["val"].to_list() == [100, 20, 30]
206
+
207
+
208
+ def test_composite_id_col(tmp_path):
209
+ repo = TableRepo(tmp_path / "data", id_col=["a", "b"])
210
+ repo.extend(pl.DataFrame({"a": [2, 1, 1], "b": [1, 2, 1], "val": [21, 12, 11]}))
211
+ repo.extend(pl.DataFrame({"a": [1, 2], "b": [3, 0], "val": [13, 20]}))
212
+ assert repo.get_full_df()["val"].to_list() == [11, 12, 13, 20, 21]
213
+ repo.replace_records(pl.DataFrame({"a": [1, 3], "b": [2, 0], "val": [-12, 30]}))
214
+ assert repo.get_full_df()["val"].to_list() == [11, -12, 13, 20, 21, 30]
215
+
216
+
190
217
  def test_replace_records_empty_repo(tmp_path):
191
218
  repo = TableRepo(tmp_path / "data", id_col="id")
192
219
  repo.replace_records(pl.DataFrame({"id": [1, 2], "val": [1, 2]}))
193
220
  assert repo.get_full_df().shape[0] == 2
194
221
 
195
222
 
223
+ def test_replace_records_empty_repo_max_records(tmp_path):
224
+ repo = TableRepo(tmp_path / "data", id_col="id", max_records=2)
225
+ repo.replace_records(
226
+ pl.DataFrame({"id": [1, 2, 3, 4, 5], "val": [10, 20, 30, 40, 50]})
227
+ )
228
+ assert repo.n_files == 3 # 5 rows at max_records=2 → (2, 2, 1)
229
+ assert all(p.name.startswith("file-") for p in repo.paths)
230
+ repo.extend(pl.DataFrame({"id": [6, 7], "val": [60, 70]}))
231
+ result = repo.get_full_df().sort("id")
232
+ assert result["id"].to_list() == [1, 2, 3, 4, 5, 6, 7]
233
+ assert all(p.name.startswith("file-") for p in repo.paths)
234
+ assert repo.n_files == 4 # 7 rows at max_records=2 → (2, 2, 2, 1)
235
+
236
+
196
237
  def test_id_col_sorted_merge_on_extend(tmp_path):
197
238
  repo = TableRepo(tmp_path / "data", id_col="id")
198
239
  repo.extend(pl.DataFrame({"id": [1, 3], "val": [10, 30]}))
@@ -243,6 +284,15 @@ def test_dedup_cols_with_id_col_old_wins(tmp_path):
243
284
  assert result.filter(pl.col("id") == 3)["val"][0] == 30 # new row inserted
244
285
 
245
286
 
287
+ def test_dedup_cols_old_wins_within_id_col_runs(tmp_path):
288
+ repo = TableRepo(tmp_path / "data", id_col="g", dedup_cols=["g", "id"])
289
+ ids = list(range(10))
290
+ df = pl.DataFrame({"g": [i // 4 for i in ids], "id": ids})
291
+ repo.extend(df.with_columns(val=pl.lit("stored")))
292
+ repo.extend(df.with_columns(val=pl.lit("incoming")))
293
+ assert repo.get_full_df()["val"].to_list() == ["stored"] * 10
294
+
295
+
246
296
  # ---------------------------------------------------------------------------
247
297
  # IPC / Parquet format
248
298
  # ---------------------------------------------------------------------------
@@ -323,6 +373,26 @@ def test_hash_partitioner_extend_twice(tmp_path):
323
373
  assert repo.get_full_df().shape[0] == 60
324
374
 
325
375
 
376
+ def test_hash_drift_raises_until_rehash(tmp_path, monkeypatch):
377
+ hp = HashPartitioner("id", num_groups=4)
378
+ df = pl.DataFrame({"id": range(100), "val": 0})
379
+ TableRepo(tmp_path / "data", id_col="id", partition_cols=hp).extend(df)
380
+
381
+ upgraded = pl.Expr.hash
382
+ monkeypatch.setattr(pl.Expr, "hash", lambda e, seed=0: upgraded(e, seed + 1))
383
+ repo = TableRepo(tmp_path / "data", id_col="id", partition_cols=hp)
384
+ assert repo.get_full_df().shape[0] == 100 # full reads are layout-agnostic
385
+ with pytest.raises(HashDriftError):
386
+ repo.replace_records(df.with_columns(val=pl.lit(1)))
387
+ with pytest.raises(HashDriftError):
388
+ repo.get_partition_df({hp.key: "0"})
389
+
390
+ repo.rehash()
391
+ fresh = TableRepo(tmp_path / "data", id_col="id", partition_cols=hp)
392
+ fresh.replace_records(df.with_columns(val=pl.lit(1)))
393
+ assert fresh.get_full_df().sort("id")["val"].to_list() == [1] * 100
394
+
395
+
326
396
  # ---------------------------------------------------------------------------
327
397
  # RecordWriter
328
398
  # ---------------------------------------------------------------------------
@@ -40,30 +40,30 @@ wheels = [
40
40
 
41
41
  [[package]]
42
42
  name = "polars"
43
- version = "1.39.0"
43
+ version = "2.0.0"
44
44
  source = { registry = "https://pypi.org/simple" }
45
45
  dependencies = [
46
46
  { name = "polars-runtime-32" },
47
47
  ]
48
- sdist = { url = "https://files.pythonhosted.org/packages/ab/b8/3a6a5b85e34af7936620f331f04f8bed235625439f5bd80832f968648618/polars-1.39.0.tar.gz", hash = "sha256:e63a25fb7682ae660e36067915a7c71a653b17f82308a8eb67a190a80daf0710", size = 728783, upload-time = "2026-03-12T14:24:47.876Z" }
48
+ sdist = { url = "https://files.pythonhosted.org/packages/8e/e9/001f371ec6a1bb54893f599ceebd56e6144fed4091f09f09fec0021a9276/polars-2.0.0.tar.gz", hash = "sha256:62da109e27a19a9d36657ee25dc035c9d3f87e7bd610526fe467dc37ea7dc115", size = 778215, upload-time = "2026-10-06T11:51:29.679Z" }
49
49
  wheels = [
50
- { url = "https://files.pythonhosted.org/packages/a7/f8/fad8470d9701c1b208cc24919a661efdf565373e77e7d06400642a759285/polars-1.39.0-py3-none-any.whl", hash = "sha256:4d1198b41bc47561673d9f54d0f595125202a3f53e3502821802958d3e60efe9", size = 823938, upload-time = "2026-03-12T14:22:37.78Z" },
50
+ { url = "https://files.pythonhosted.org/packages/ac/09/cc33bbd5463749c116b62c204d88bed6c02a6cb901eac7adab0d38651b07/polars-2.0.0-py3-none-any.whl", hash = "sha256:35d62f3541b7a6d4c360a2e2f07fccc0c2bcbd33b0ea51c83a25417a47a3f3ad", size = 876611, upload-time = "2026-10-06T11:44:04.327Z" },
51
51
  ]
52
52
 
53
53
  [[package]]
54
54
  name = "polars-runtime-32"
55
- version = "1.39.0"
55
+ version = "2.0.0"
56
56
  source = { registry = "https://pypi.org/simple" }
57
- sdist = { url = "https://files.pythonhosted.org/packages/8b/1e/fce83ad77bfed1bf4a83f74dde19e2572c32fc040e93bd98d161e3950eaf/polars_runtime_32-1.39.0.tar.gz", hash = "sha256:f5aabed8c7318fcad5173e83bee385445f54b5f8c83b1ec9eab78bdffa293141", size = 2870686, upload-time = "2026-03-12T14:24:49.41Z" }
57
+ sdist = { url = "https://files.pythonhosted.org/packages/34/ad/dbb6f6d7070867951532bcfe5e6a648d8777b416b18cddabc07030404e8c/polars_runtime_32-2.0.0.tar.gz", hash = "sha256:b5f9afcc742b4a67eabd2c680ff0f12eb02ede9b4bf807bffabd6dbb9a58d5c7", size = 3591339, upload-time = "2026-10-06T11:51:31.076Z" }
58
58
  wheels = [
59
- { url = "https://files.pythonhosted.org/packages/2b/6d/143b552baa9e859ae266f087f3ec0aeb29e5acc39e1f49c1a64023cee469/polars_runtime_32-1.39.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4a4bc06ca97238d963979e3f888fbb500ee607f03cefe43a9062381e259503e2", size = 45299222, upload-time = "2026-03-12T14:22:40.821Z" },
60
- { url = "https://files.pythonhosted.org/packages/97/ec/eb4e57eedfb97019f951b298fa4cd232a50db65aa6702c735b6f272a0fa0/polars_runtime_32-1.39.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9914b9e168634bc21d07ee03b8fa92d0aaa8ac7b2bb1c9e2f1f78622aa1b8f4", size = 40863978, upload-time = "2026-03-12T14:22:45.16Z" },
61
- { url = "https://files.pythonhosted.org/packages/5f/b7/28fa0345586f7c449dd27d687c32a10dcea470ebc5a978d7fc47e463b298/polars_runtime_32-1.39.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ded58f1c28e17ecbff8625cb1ad93016761260348acb79b1a4cd077970e89e5", size = 43231627, upload-time = "2026-03-12T14:22:49.464Z" },
62
- { url = "https://files.pythonhosted.org/packages/cf/60/c0d0b6720437685223457242a79f6bba443485ca85928645786479ebed86/polars_runtime_32-1.39.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b82c872b25ef6628462f90f1b6b3950779aee36889e83b3693d0a69684d3d86a", size = 46899324, upload-time = "2026-03-12T14:22:54.364Z" },
63
- { url = "https://files.pythonhosted.org/packages/73/98/53ad9c8a6f151e098e4f65c5146f9e538f1ba148feb5289fd2a4c5e2d764/polars_runtime_32-1.39.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4a0e9d6b56362f3ba1a33d0538ae14c9b9a8e0fb835f86abfc82fa7b2c7d89c9", size = 43389283, upload-time = "2026-03-12T14:22:59.767Z" },
64
- { url = "https://files.pythonhosted.org/packages/74/a2/21f77d6e588ee7c8e7f6232d135538690411de2ea6415d8bbe9b8d684f37/polars_runtime_32-1.39.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0daea3919661ba672b00bd01b5547cd29bb6414732457abb72cbc75103cf3c90", size = 46509946, upload-time = "2026-03-12T14:23:05.215Z" },
65
- { url = "https://files.pythonhosted.org/packages/24/a3/37a56ad2d931c857b892b22760b9bf9a53f681d9ccf27741cf6dd8489320/polars_runtime_32-1.39.0-cp310-abi3-win_amd64.whl", hash = "sha256:d6e9d1cf264aacfe5bf03241c04ef435d0f9cfec3fbe079acc3a7328a737961a", size = 47012669, upload-time = "2026-03-12T14:23:11.134Z" },
66
- { url = "https://files.pythonhosted.org/packages/b3/eb/936f5eeae196e8c8aaabe5f7d98891be8a5bbc741d50ce5c60f55575ad29/polars_runtime_32-1.39.0-cp310-abi3-win_arm64.whl", hash = "sha256:d69abde5f148566860bbe910010847bd7791e72f7c8063a4d2c462246a33a72a", size = 41885761, upload-time = "2026-03-12T14:23:16.773Z" },
59
+ { url = "https://files.pythonhosted.org/packages/82/88/d35dec6c8928dfbaa1cccf9b626a1067da906e792c92d9f994ca825ab2b5/polars_runtime_32-2.0.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ffb7ac6cf4e8c4a652df1951e3c3840c7c23a033603d5a9efd422fa8dd699d82", size = 52494314, upload-time = "2026-10-06T11:44:07.768Z" },
60
+ { url = "https://files.pythonhosted.org/packages/5f/fd/2237bf53ffaff47cdf1edc6c10587a7a6444d4951150eeb08d84f3493ff8/polars_runtime_32-2.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:7012d8a0201bd95638545ce8f256c0efe2c5cab0f806eb043021dddde5a9498b", size = 47930083, upload-time = "2026-10-06T11:44:11.592Z" },
61
+ { url = "https://files.pythonhosted.org/packages/0d/0d/85e3ed90417996fc09770be91b39979074fe2978fc15b431bf8a9459760d/polars_runtime_32-2.0.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b85bb42e6009acc9629afcc70a83473fd468694d6a30ffb0ab376c8dd1a0a17", size = 50417889, upload-time = "2026-10-06T11:50:20.774Z" },
62
+ { url = "https://files.pythonhosted.org/packages/83/88/e9fecfd49159da92f54ff2445883577a0f1bc195da53ecc9535c458d55dd/polars_runtime_32-2.0.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d6ac584ea2b38913784db943879412380d92e28ab9cb88e20a77ba71ba3f911", size = 54475036, upload-time = "2026-10-06T11:50:24.411Z" },
63
+ { url = "https://files.pythonhosted.org/packages/48/ad/b2abf732697b21467aaaeaac0f3bf7eee0d89c59ce8125f1ed41b28a2d97/polars_runtime_32-2.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a6bf5e260e0a6f00d0f9181438fe9e45776df8c66cee9cba16e3675cc3888488", size = 50579474, upload-time = "2026-10-06T11:50:28.377Z" },
64
+ { url = "https://files.pythonhosted.org/packages/7f/05/304deee59a95865e1b5e9ec7b066069b49093b81b768f473d9d3b165c686/polars_runtime_32-2.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55c26eef325b6840584d91aac232e9cf3ac19e1b904594b9b54131be1edeab4d", size = 54413293, upload-time = "2026-10-06T11:50:31.828Z" },
65
+ { url = "https://files.pythonhosted.org/packages/61/59/8c9fd7199f7c4eb1b64e640306a946a2e4a46337b3bbb33b840972c7d84b/polars_runtime_32-2.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:7da1caf3c7b4f397fb213c984013a0c755557619a2d511899a1ff74392484078", size = 54229989, upload-time = "2026-10-06T11:50:35.206Z" },
66
+ { url = "https://files.pythonhosted.org/packages/e2/93/43608026f38aa6ed4d22da8597706a61682ee403caef0021ce8e6dc73227/polars_runtime_32-2.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:c30ba698c8904048df4a9bc3d6c5033cc2d0a7cbb0e13f4fd2de5a1947b61994", size = 48730655, upload-time = "2026-10-06T11:50:38.756Z" },
67
67
  ]
68
68
 
69
69
  [[package]]
@@ -104,7 +104,7 @@ dev = [
104
104
  ]
105
105
 
106
106
  [package.metadata]
107
- requires-dist = [{ name = "polars" }]
107
+ requires-dist = [{ name = "polars", specifier = ">=2" }]
108
108
 
109
109
  [package.metadata.requires-dev]
110
110
  dev = [{ name = "pytest", specifier = ">=9.0.2" }]
@@ -1,15 +0,0 @@
1
- name: Python Package Release
2
-
3
- on:
4
- release:
5
- types: [published]
6
-
7
- jobs:
8
- release:
9
- runs-on: ubuntu-latest
10
- steps:
11
- - uses: actions/checkout@v4
12
- - uses: astral-sh/setup-uv@v5
13
- - run: uv build && uv publish
14
- env:
15
- UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
@@ -1,9 +0,0 @@
1
- ## v1.2.0
2
-
3
- deduped
4
-
5
- ---
6
-
7
- ## v1.1.0
8
-
9
- realign, simplify
@@ -1,7 +0,0 @@
1
- """Polars-based persistent tabular store backed by Parquet or Arrow IPC files."""
2
-
3
- from .core import DfBatchWriter, HashPartitioner, RecordWriter, TableRepo # noqa: F401
4
-
5
- TableShack = TableRepo # backward-compat alias
6
-
7
- __version__ = "1.2.0"
File without changes
File without changes
File without changes
File without changes
File without changes