shackleton 1.1.0__tar.gz → 1.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shackleton
3
- Version: 1.1.0
3
+ Version: 1.2.0
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>
@@ -110,10 +110,30 @@ 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 — only the first occurrence is kept. Combine
117
+ with `id_col` for sorted-merge dedup; use alone for unordered dedup.
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 — existing rows are kept, new ones appended
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 that column
135
+ (when `max_records == 0`) and `replace_records` does an upsert: matching IDs
136
+ are updated, new IDs appended.
117
137
 
118
138
  ```python
119
139
  repo = TableRepo(Path("/tmp/prices"), id_col="ticker")
@@ -198,6 +218,41 @@ df = repo.get_full_df()
198
218
 
199
219
  ---
200
220
 
221
+ ## Partition-level overwrite
222
+
223
+ `replace_partition` atomically clears and rewrites only the partitions covered
224
+ by the DataFrame. `purge_partition` deletes matching files without rewriting.
225
+
226
+ ```python
227
+ repo = TableRepo(Path("/tmp/events"), partition_cols=["date"])
228
+ repo.extend(pl.DataFrame({
229
+ "date": ["2024-01-01", "2024-01-01", "2024-01-02"],
230
+ "val": [1, 2, 3],
231
+ }))
232
+
233
+ # Replace just the 2024-01-01 partition with corrected data
234
+ repo.replace_partition(pl.DataFrame({
235
+ "date": ["2024-01-01"],
236
+ "val": [99],
237
+ }))
238
+
239
+ # Or wipe it without rewriting
240
+ repo.purge_partition({"date": "2024-01-01"})
241
+ ```
242
+
243
+ ---
244
+
245
+ ## Compression
246
+
247
+ Control the codec and, for Parquet, the compression level.
248
+
249
+ ```python
250
+ repo = TableRepo(Path("/tmp/data"), compression="zstd", compression_level=3)
251
+ repo_ipc = TableRepo(Path("/tmp/fast"), ipc=True, compression="lz4")
252
+ ```
253
+
254
+ ---
255
+
201
256
  ## Arrow IPC format
202
257
 
203
258
  Slightly faster reads for local workloads that don't need Parquet compatibility.
@@ -92,10 +92,30 @@ 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 — only the first occurrence is kept. Combine
99
+ with `id_col` for sorted-merge dedup; use alone for unordered dedup.
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 — existing rows are kept, new ones appended
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 that column
117
+ (when `max_records == 0`) and `replace_records` does an upsert: matching IDs
118
+ are updated, new IDs appended.
99
119
 
100
120
  ```python
101
121
  repo = TableRepo(Path("/tmp/prices"), id_col="ticker")
@@ -180,6 +200,41 @@ df = repo.get_full_df()
180
200
 
181
201
  ---
182
202
 
203
+ ## Partition-level overwrite
204
+
205
+ `replace_partition` atomically clears and rewrites only the partitions covered
206
+ by the DataFrame. `purge_partition` deletes matching files without rewriting.
207
+
208
+ ```python
209
+ repo = TableRepo(Path("/tmp/events"), partition_cols=["date"])
210
+ repo.extend(pl.DataFrame({
211
+ "date": ["2024-01-01", "2024-01-01", "2024-01-02"],
212
+ "val": [1, 2, 3],
213
+ }))
214
+
215
+ # Replace just the 2024-01-01 partition with corrected data
216
+ repo.replace_partition(pl.DataFrame({
217
+ "date": ["2024-01-01"],
218
+ "val": [99],
219
+ }))
220
+
221
+ # Or wipe it without rewriting
222
+ repo.purge_partition({"date": "2024-01-01"})
223
+ ```
224
+
225
+ ---
226
+
227
+ ## Compression
228
+
229
+ Control the codec and, for Parquet, the compression level.
230
+
231
+ ```python
232
+ repo = TableRepo(Path("/tmp/data"), compression="zstd", compression_level=3)
233
+ repo_ipc = TableRepo(Path("/tmp/fast"), ipc=True, compression="lz4")
234
+ ```
235
+
236
+ ---
237
+
183
238
  ## Arrow IPC format
184
239
 
185
240
  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%** |
@@ -1,3 +1,9 @@
1
+ ## v1.2.0
2
+
3
+ deduped
4
+
5
+ ---
6
+
1
7
  ## v1.1.0
2
8
 
3
9
  realign, simplify
@@ -4,4 +4,4 @@ from .core import DfBatchWriter, HashPartitioner, RecordWriter, TableRepo # noq
4
4
 
5
5
  TableShack = TableRepo # backward-compat alias
6
6
 
7
- __version__ = "1.1.0"
7
+ __version__ = "1.2.0"
@@ -3,7 +3,7 @@ import fcntl
3
3
  import threading
4
4
  from concurrent.futures import ThreadPoolExecutor
5
5
  from dataclasses import dataclass, field
6
- from functools import cached_property, partial
6
+ from functools import cached_property
7
7
  from pathlib import Path
8
8
  from typing import Any, Callable, Iterable, TypeVar
9
9
 
@@ -76,7 +76,12 @@ class TableRepo:
76
76
  Args:
77
77
  root_path: Root directory for all data files.
78
78
  id_col: Column used as a unique row identifier. Required for
79
- ``replace_records``; enables sorted merge on extend.
79
+ ``replace_records``; enables sorted merge on ``extend`` when
80
+ ``max_records == 0`` (no effect on the per-file append path).
81
+ 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``.
80
85
  partition_cols: Column name(s) to partition by, or a
81
86
  ``HashPartitioner`` for hash-based bucketing. Partition values
82
87
  are encoded as hive-style directories (``col=val/``).
@@ -86,14 +91,22 @@ class TableRepo:
86
91
  ipc: Use Arrow IPC format instead of Parquet.
87
92
  compression: Compression codec passed to the writer
88
93
  (e.g. ``"zstd"``, ``"gzip"``). ``None`` uses the writer default.
94
+ compression_level: Compression level for Parquet writers. ``None`` uses
95
+ the codec default. Ignored for Arrow IPC.
89
96
  """
90
97
 
91
98
  root_path: Path
92
99
  id_col: str | None = None
100
+ dedup_cols: list[str] | None = None
93
101
  partition_cols: list[str] | HashPartitioner = field(default_factory=list)
94
102
  max_records: int = 0
95
103
  ipc: bool = False
96
104
  compression: str | None = None
105
+ compression_level: int | None = None
106
+
107
+ def __post_init__(self) -> None:
108
+ if self.dedup_cols and self.max_records:
109
+ raise ValueError("dedup_cols is not supported with max_records > 0")
97
110
 
98
111
  @cached_property
99
112
  def extension(self) -> str:
@@ -119,6 +132,20 @@ class TableRepo:
119
132
  self.purge()
120
133
  self.extend(df)
121
134
 
135
+ def purge_partition(self, partition_values: dict[str, Any]) -> None:
136
+ """Delete all files in the matching partition(s)."""
137
+ for d in {p.parent for p in self._partition_paths(partition_values)}:
138
+ self._purge_dir(d)
139
+
140
+ def replace_partition(self, df: pl.DataFrame) -> None:
141
+ """Overwrite the partition(s) covered by ``df``, deleting existing files first.
142
+
143
+ ``df`` must contain the partition column(s).
144
+ """
145
+ if not self._part_keys:
146
+ raise ValueError("replace_partition requires partition_cols to be set")
147
+ self._for_each_partition(df, self._replace_partition_dir)
148
+
122
149
  def purge(self) -> None:
123
150
  """Delete all data files."""
124
151
  for p in self.paths:
@@ -265,15 +292,30 @@ class TableRepo:
265
292
 
266
293
  def _write(self, df: pl.DataFrame, path: Path) -> None:
267
294
  path.parent.mkdir(parents=True, exist_ok=True)
268
- write_fn = pl.DataFrame.write_ipc if self.ipc else pl.DataFrame.write_parquet
295
+ kw: dict = {}
269
296
  if self.compression:
270
- write_fn = partial(write_fn, compression=self.compression)
271
- write_fn(df, path)
297
+ kw["compression"] = self.compression
298
+ if self.ipc:
299
+ df.write_ipc(path, **kw)
300
+ else:
301
+ if self.compression_level is not None:
302
+ kw["compression_level"] = self.compression_level
303
+ df.write_parquet(path, **kw)
272
304
 
273
305
  def _merge_extend(self, new: pl.LazyFrame, old: pl.LazyFrame) -> pl.LazyFrame:
274
306
  if self.id_col:
275
- return old.merge_sorted(new.sort(self.id_col), key=self.id_col)
276
- return pl.concat([old, new], how="diagonal")
307
+ out = old.merge_sorted(new.sort(self.id_col), key=self.id_col)
308
+ else:
309
+ out = pl.concat([old, new], how="diagonal")
310
+ 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
313
+ out = out.unique(
314
+ subset=self.dedup_cols,
315
+ keep="first" if self.id_col else "any",
316
+ maintain_order=bool(self.id_col),
317
+ )
318
+ return out
277
319
 
278
320
  def _merge_replace(self, new: pl.LazyFrame, old: pl.LazyFrame) -> pl.LazyFrame:
279
321
  return (
@@ -308,11 +350,24 @@ class TableRepo:
308
350
  else:
309
351
  path = self._single_path(root)
310
352
  if not path.exists():
311
- out = df.sort(self.id_col) if self.id_col else df
353
+ out = df
354
+ if self.dedup_cols:
355
+ out = out.unique(subset=self.dedup_cols, maintain_order=False)
356
+ if self.id_col:
357
+ out = out.sort(self.id_col)
312
358
  else:
313
359
  out = self._merge_extend(df.lazy(), self._lazy_read(path)).collect()
314
360
  self._write(out, path)
315
361
 
362
+ def _purge_dir(self, root: Path) -> None:
363
+ with _dir_lock(str(root)):
364
+ for p in root.glob("*" + self.extension):
365
+ p.unlink()
366
+
367
+ def _replace_partition_dir(self, df: pl.DataFrame, root: Path) -> None:
368
+ self._purge_dir(root)
369
+ self._extend_dir(df, root)
370
+
316
371
  def _replace_dir(self, df: pl.DataFrame, root: Path) -> None:
317
372
  with _dir_lock(str(root)):
318
373
  existing = sorted(root.glob("*" + self.extension))
@@ -7,21 +7,16 @@ import pytest
7
7
 
8
8
  from shackleton import HashPartitioner, TableRepo
9
9
 
10
- # ---------------------------------------------------------------------------
11
- # Fixtures / helpers
12
- # ---------------------------------------------------------------------------
13
-
14
10
 
15
- def make_df(n: int, seed: int = 0, with_id: bool = False) -> pl.DataFrame:
11
+ def make_df(n: int, seed: int = 0) -> pl.DataFrame:
16
12
  rng = random.Random(seed)
17
- data: dict = {
18
- "A": [rng.random() for _ in range(n)],
19
- "B": [rng.randint(0, 9) for _ in range(n)],
20
- "C": [rng.choice(list("xyz")) for _ in range(n)],
21
- }
22
- if with_id:
23
- data["id"] = list(range(n))
24
- return pl.DataFrame(data)
13
+ return pl.DataFrame(
14
+ {
15
+ "A": [rng.random() for _ in range(n)],
16
+ "B": [rng.randint(0, 9) for _ in range(n)],
17
+ "C": [rng.choice(list("xyz")) for _ in range(n)],
18
+ }
19
+ )
25
20
 
26
21
 
27
22
  # ---------------------------------------------------------------------------
@@ -31,10 +26,8 @@ def make_df(n: int, seed: int = 0, with_id: bool = False) -> pl.DataFrame:
31
26
 
32
27
  def test_extend_single_file(tmp_path):
33
28
  repo = TableRepo(tmp_path / "data")
34
- df1 = make_df(10, seed=1)
35
- df2 = make_df(5, seed=2)
36
- repo.extend(df1)
37
- repo.extend(df2)
29
+ repo.extend(make_df(10, seed=1))
30
+ repo.extend(make_df(5, seed=2))
38
31
  assert repo.get_full_df().shape[0] == 15
39
32
  assert repo.n_files == 1
40
33
 
@@ -42,10 +35,8 @@ def test_extend_single_file(tmp_path):
42
35
  def test_replace_all(tmp_path):
43
36
  repo = TableRepo(tmp_path / "data")
44
37
  repo.extend(make_df(20))
45
- df = make_df(7, seed=99)
46
- repo.replace_all(df)
47
- result = repo.get_full_df()
48
- assert result.shape[0] == 7
38
+ repo.replace_all(make_df(7, seed=99))
39
+ assert repo.get_full_df().shape[0] == 7
49
40
 
50
41
 
51
42
  def test_purge(tmp_path):
@@ -90,15 +81,13 @@ def test_max_records_multiple_extends(tmp_path):
90
81
  for seed in range(4):
91
82
  repo.extend(make_df(4, seed=seed))
92
83
  assert repo.get_full_df().shape[0] == 16
93
- # 16 rows at 5/file → 4 files (5, 5, 5, 1)
94
- assert repo.n_files == 4
84
+ assert repo.n_files == 4 # 16 rows at 5/file → (5,5,5,1)
95
85
 
96
86
 
97
87
  def test_max_records_fills_last_file(tmp_path):
98
88
  repo = TableRepo(tmp_path / "data", max_records=10)
99
89
  repo.extend(make_df(7))
100
90
  repo.extend(make_df(6))
101
- # 13 rows → file-0: 10, file-1: 3
102
91
  assert repo.n_files == 2
103
92
  assert repo.get_full_df().shape[0] == 13
104
93
 
@@ -108,20 +97,11 @@ def test_max_records_fills_last_file(tmp_path):
108
97
  # ---------------------------------------------------------------------------
109
98
 
110
99
 
111
- def test_partition_single_col(tmp_path):
112
- repo = TableRepo(tmp_path / "data", partition_cols=["B"])
113
- df = make_df(100)
114
- repo.extend(df)
115
- full = repo.get_full_df()
116
- assert full.shape[0] == 100
117
- assert set(full["B"].to_list()).issubset(set(range(10)))
118
-
119
-
120
- def test_partition_multi_col(tmp_path):
121
- repo = TableRepo(tmp_path / "data", partition_cols=["B", "C"])
122
- df = make_df(200)
123
- repo.extend(df)
124
- assert repo.get_full_df().shape[0] == 200
100
+ @pytest.mark.parametrize("partition_cols", [["B"], ["B", "C"]])
101
+ def test_partition_extend(tmp_path, partition_cols):
102
+ repo = TableRepo(tmp_path / "data", partition_cols=partition_cols)
103
+ repo.extend(make_df(100))
104
+ assert repo.get_full_df().shape[0] == 100
125
105
 
126
106
 
127
107
  def test_get_partition(tmp_path):
@@ -136,11 +116,13 @@ def test_get_partition(tmp_path):
136
116
  }
137
117
  )
138
118
  repo.extend(df)
139
- part = repo.get_partition_df({"B": 4})
140
- assert part.shape[0] == df.filter(pl.col("B") == 4).shape[0]
141
-
142
- part2 = repo.get_partition_df({"C": byte_set[0]})
143
- assert part2.shape[0] == df.filter(pl.col("C") == byte_set[0]).shape[0]
119
+ assert (
120
+ repo.get_partition_df({"B": 4}).shape[0] == df.filter(pl.col("B") == 4).shape[0]
121
+ )
122
+ assert (
123
+ repo.get_partition_df({"C": byte_set[0]}).shape[0]
124
+ == df.filter(pl.col("C") == byte_set[0]).shape[0]
125
+ )
144
126
 
145
127
 
146
128
  def test_partition_extend_twice(tmp_path):
@@ -154,8 +136,7 @@ def test_partition_extend_twice(tmp_path):
154
136
  def test_partition_replace_all(tmp_path):
155
137
  repo = TableRepo(tmp_path / "data", partition_cols=["C"])
156
138
  repo.extend(make_df(30))
157
- df2 = make_df(10, seed=99)
158
- repo.replace_all(df2)
139
+ repo.replace_all(make_df(10, seed=99))
159
140
  assert repo.get_full_df().shape[0] == 10
160
141
 
161
142
 
@@ -163,7 +144,6 @@ def test_partition_max_records(tmp_path):
163
144
  repo = TableRepo(tmp_path / "data", partition_cols=["C"], max_records=3)
164
145
  repo.extend(make_df(30))
165
146
  assert repo.get_full_df().shape[0] == 30
166
- # Each partition ("x", "y", "z") should have multiple files
167
147
  assert repo.n_files > 3
168
148
 
169
149
 
@@ -172,14 +152,10 @@ def test_partition_max_records(tmp_path):
172
152
  # ---------------------------------------------------------------------------
173
153
 
174
154
 
175
- def test_replace_records_basic(tmp_path):
155
+ def test_replace_records_insert_and_update(tmp_path):
176
156
  repo = TableRepo(tmp_path / "data", id_col="id")
177
- df = pl.DataFrame({"id": [1, 2, 3], "val": [10, 20, 30]})
178
- repo.extend(df)
179
-
180
- updates = pl.DataFrame({"id": [2, 4], "val": [200, 400]})
181
- repo.replace_records(updates)
182
-
157
+ repo.extend(pl.DataFrame({"id": [1, 2, 3], "val": [10, 20, 30]}))
158
+ repo.replace_records(pl.DataFrame({"id": [2, 4], "val": [200, 400]}))
183
159
  result = repo.get_full_df().sort("id")
184
160
  assert result["id"].to_list() == [1, 2, 3, 4]
185
161
  assert result["val"].to_list() == [10, 200, 30, 400]
@@ -193,25 +169,18 @@ def test_replace_records_no_id_raises(tmp_path):
193
169
 
194
170
  def test_replace_records_dedup(tmp_path):
195
171
  repo = TableRepo(tmp_path / "data", id_col="id")
196
- df = pl.DataFrame({"id": [1, 2, 3], "val": [1, 2, 3]})
197
- repo.extend(df)
198
- # Replace with duplicate ids in the input — last wins
199
- dups = pl.DataFrame({"id": [2, 2], "val": [20, 21]})
200
- repo.replace_records(dups)
172
+ repo.extend(pl.DataFrame({"id": [1, 2, 3], "val": [1, 2, 3]}))
173
+ repo.replace_records(pl.DataFrame({"id": [2, 2], "val": [20, 21]}))
201
174
  result = repo.get_full_df().sort("id")
202
- assert result.filter(pl.col("id") == 2)["val"][0] in (20, 21)
203
175
  assert result.shape[0] == 3
176
+ assert result.filter(pl.col("id") == 2)["val"][0] in (20, 21)
204
177
 
205
178
 
206
179
  def test_replace_records_with_max_records(tmp_path):
207
180
  repo = TableRepo(tmp_path / "data", id_col="id", max_records=2)
208
- df = pl.DataFrame({"id": [1, 2, 3], "val": [10, 20, 30]})
209
- repo.extend(df)
210
- assert repo.n_files == 2 # 2 + 1
211
-
212
- updates = pl.DataFrame({"id": [2, 3, 4], "val": [200, 300, 400]})
213
- repo.replace_records(updates)
214
-
181
+ repo.extend(pl.DataFrame({"id": [1, 2, 3], "val": [10, 20, 30]}))
182
+ assert repo.n_files == 2 # 2+1
183
+ repo.replace_records(pl.DataFrame({"id": [2, 3, 4], "val": [200, 300, 400]}))
215
184
  result = repo.get_full_df().sort("id")
216
185
  assert result["id"].to_list() == [1, 2, 3, 4]
217
186
  assert result["val"].to_list() == [10, 200, 300, 400]
@@ -220,27 +189,77 @@ def test_replace_records_with_max_records(tmp_path):
220
189
 
221
190
  def test_replace_records_empty_repo(tmp_path):
222
191
  repo = TableRepo(tmp_path / "data", id_col="id")
223
- df = pl.DataFrame({"id": [1, 2], "val": [1, 2]})
224
- repo.replace_records(df)
192
+ repo.replace_records(pl.DataFrame({"id": [1, 2], "val": [1, 2]}))
225
193
  assert repo.get_full_df().shape[0] == 2
226
194
 
227
195
 
196
+ def test_id_col_sorted_merge_on_extend(tmp_path):
197
+ repo = TableRepo(tmp_path / "data", id_col="id")
198
+ repo.extend(pl.DataFrame({"id": [1, 3], "val": [10, 30]}))
199
+ repo.extend(pl.DataFrame({"id": [2, 4], "val": [20, 40]}))
200
+ result = repo.get_full_df()
201
+ assert result["id"].to_list() == [1, 2, 3, 4]
202
+ assert result["val"].to_list() == [10, 20, 30, 40]
203
+
204
+
228
205
  # ---------------------------------------------------------------------------
229
- # IPC format
206
+ # dedup_cols
230
207
  # ---------------------------------------------------------------------------
231
208
 
232
209
 
233
- def test_ipc_format(tmp_path):
234
- repo = TableRepo(tmp_path / "data", ipc=True)
235
- df = make_df(20)
236
- repo.extend(df)
210
+ def test_dedup_cols_max_records_raises(tmp_path):
211
+ with pytest.raises(ValueError, match="dedup_cols"):
212
+ TableRepo(tmp_path / "data", dedup_cols=["id"], max_records=100)
213
+
214
+
215
+ def test_dedup_cols_initial_write(tmp_path):
216
+ repo = TableRepo(tmp_path / "data", dedup_cols=["id"])
217
+ repo.extend(pl.DataFrame({"id": [1, 1, 2], "val": [10, 99, 20]}))
218
+ result = repo.get_full_df()
219
+ assert result.shape[0] == 2
220
+ assert set(result["id"].to_list()) == {1, 2}
221
+
222
+
223
+ def test_dedup_cols_extend(tmp_path):
224
+ repo = TableRepo(tmp_path / "data", dedup_cols=["id"])
225
+ repo.extend(pl.DataFrame({"id": [1, 2, 3], "val": [10, 20, 30]}))
226
+ repo.extend(pl.DataFrame({"id": [2, 4], "val": [99, 40]}))
227
+ result = repo.get_full_df().sort("id")
228
+ assert result.shape[0] == 4
229
+ assert set(result["id"].to_list()) == {1, 2, 3, 4}
230
+ # id=4 is new, so its value is unambiguous
231
+ assert result.filter(pl.col("id") == 4)["val"][0] == 40
232
+
233
+
234
+ def test_dedup_cols_with_id_col_old_wins(tmp_path):
235
+ # id_col + dedup_cols: merge_sorted places old rows first, keep="first" retains them
236
+ repo = TableRepo(tmp_path / "data", id_col="id", dedup_cols=["id"])
237
+ repo.extend(pl.DataFrame({"id": [1, 2], "val": [10, 20]}))
238
+ repo.extend(pl.DataFrame({"id": [2, 3], "val": [99, 30]}))
239
+ result = repo.get_full_df().sort("id")
240
+ assert result.shape[0] == 3
241
+ assert result["id"].to_list() == [1, 2, 3]
242
+ assert result.filter(pl.col("id") == 2)["val"][0] == 20 # old value kept
243
+ assert result.filter(pl.col("id") == 3)["val"][0] == 30 # new row inserted
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # IPC / Parquet format
248
+ # ---------------------------------------------------------------------------
249
+
250
+
251
+ @pytest.mark.parametrize("ipc", [False, True])
252
+ def test_format_basic(tmp_path, ipc):
253
+ repo = TableRepo(tmp_path / "data", ipc=ipc)
254
+ repo.extend(make_df(20))
237
255
  assert repo.n_files == 1
238
- assert list(repo.paths)[0].suffix == ".arrow"
256
+ assert list(repo.paths)[0].suffix == (".arrow" if ipc else ".parquet")
239
257
  assert repo.get_full_df().shape[0] == 20
240
258
 
241
259
 
242
- def test_ipc_with_partitions(tmp_path):
243
- repo = TableRepo(tmp_path / "data", ipc=True, partition_cols=["C"])
260
+ @pytest.mark.parametrize("ipc", [False, True])
261
+ def test_format_with_partitions(tmp_path, ipc):
262
+ repo = TableRepo(tmp_path / "data", ipc=ipc, partition_cols=["C"])
244
263
  repo.extend(make_df(30))
245
264
  assert repo.get_full_df().shape[0] == 30
246
265
 
@@ -250,8 +269,15 @@ def test_ipc_with_partitions(tmp_path):
250
269
  # ---------------------------------------------------------------------------
251
270
 
252
271
 
253
- def test_compression(tmp_path):
254
- repo = TableRepo(tmp_path / "data", compression="zstd")
272
+ @pytest.mark.parametrize(
273
+ "kw",
274
+ [
275
+ {"compression": "zstd"},
276
+ {"compression": "zstd", "compression_level": 1},
277
+ ],
278
+ )
279
+ def test_compression(tmp_path, kw):
280
+ repo = TableRepo(tmp_path / "data", **kw)
255
281
  repo.extend(make_df(50))
256
282
  assert repo.get_full_df().shape[0] == 50
257
283
 
@@ -264,12 +290,8 @@ def test_compression(tmp_path):
264
290
  def test_hash_partitioner_distributes(tmp_path):
265
291
  hp = HashPartitioner("B", num_groups=4)
266
292
  repo = TableRepo(tmp_path / "data", partition_cols=hp)
267
- df = make_df(200)
268
- repo.extend(df)
269
-
270
- total = repo.get_full_df().shape[0]
271
- assert total == 200
272
- # Buckets: 1–4 files (collisions allowed); at least 1
293
+ repo.extend(make_df(200))
294
+ assert repo.get_full_df().shape[0] == 200
273
295
  assert 1 <= repo.n_files <= 4
274
296
 
275
297
 
@@ -277,8 +299,7 @@ def test_hash_partitioner_key_stripped(tmp_path):
277
299
  hp = HashPartitioner("B", num_groups=4)
278
300
  repo = TableRepo(tmp_path / "data", partition_cols=hp)
279
301
  repo.extend(make_df(20))
280
- df = repo.get_full_df()
281
- assert hp.key not in df.columns
302
+ assert hp.key not in repo.get_full_df().columns
282
303
 
283
304
 
284
305
  def test_hash_partitioner_get_partition(tmp_path):
@@ -286,13 +307,11 @@ def test_hash_partitioner_get_partition(tmp_path):
286
307
  repo = TableRepo(tmp_path / "data", partition_cols=hp)
287
308
  df = make_df(100)
288
309
  repo.extend(df)
289
-
290
- # All rows with B=3 land in the same bucket
291
310
  b3_bucket = str(int(pl.Series([3]).cast(pl.String).hash()[0]) % 8).zfill(1)
292
311
  part = repo.get_partition_df({hp.key: b3_bucket})
293
- b3_count = df.filter(pl.col("B") == 3).shape[0]
294
- # Partition contains all B=3 rows (and possibly other B values in same bucket)
295
- assert part.filter(pl.col("B") == 3).shape[0] == b3_count
312
+ assert (
313
+ part.filter(pl.col("B") == 3).shape[0] == df.filter(pl.col("B") == 3).shape[0]
314
+ )
296
315
 
297
316
 
298
317
  def test_hash_partitioner_extend_twice(tmp_path):
@@ -314,29 +333,30 @@ def test_record_writer_extend(tmp_path):
314
333
  with repo.get_extending_record_writer(7) as writer:
315
334
  for i in range(30):
316
335
  writer.add_to_batch({"i": i, "val": i * 2})
317
- assert repo.get_full_df().shape[0] == 30
336
+ result = repo.get_full_df().sort("i")
337
+ assert result.shape[0] == 30
338
+ assert result["val"].to_list() == [i * 2 for i in range(30)]
318
339
 
319
340
 
320
341
  def test_record_writer_replace(tmp_path):
321
342
  repo = TableRepo(tmp_path / "data", id_col="i")
322
- df = pl.DataFrame({"i": list(range(10)), "val": list(range(10))})
323
- repo.extend(df)
324
-
343
+ repo.extend(pl.DataFrame({"i": list(range(10)), "val": list(range(10))}))
325
344
  with repo.get_replacing_record_writer(3) as writer:
326
345
  for i in range(5):
327
346
  writer.add_to_batch({"i": i, "val": i * 100})
328
-
329
347
  result = repo.get_full_df().sort("i")
330
348
  assert result.shape[0] == 10
331
- assert result.filter(pl.col("i") == 0)["val"][0] == 0
349
+ assert result.filter(pl.col("i") < 5)["val"].to_list() == [
350
+ i * 100 for i in range(5)
351
+ ]
352
+ assert result.filter(pl.col("i") >= 5)["val"].to_list() == list(range(5, 10))
332
353
 
333
354
 
334
355
  def test_record_writer_context_manager_flushes(tmp_path):
335
356
  repo = TableRepo(tmp_path / "data")
336
357
  with repo.get_extending_record_writer(100) as writer:
337
- for i in range(5): # below batch_size
358
+ for i in range(5):
338
359
  writer.add_to_batch({"i": i})
339
- # context exit should have flushed
340
360
  assert repo.get_full_df().shape[0] == 5
341
361
 
342
362
 
@@ -347,29 +367,22 @@ def test_record_writer_context_manager_flushes(tmp_path):
347
367
 
348
368
  def test_df_writer_extend(tmp_path):
349
369
  repo = TableRepo(tmp_path / "data")
350
- dfs = [make_df(10, seed=i) for i in range(5)]
351
370
  with repo.get_extending_df_writer(25) as writer:
352
- for df in dfs:
353
- writer.add_to_batch(df)
371
+ for i in range(5):
372
+ writer.add_to_batch(make_df(10, seed=i))
354
373
  assert repo.get_full_df().shape[0] == 50
355
374
 
356
375
 
357
376
  def test_df_writer_replaces(tmp_path):
358
377
  repo = TableRepo(tmp_path / "data", id_col="id")
359
- base = pl.DataFrame({"id": list(range(20)), "val": list(range(20))})
360
- repo.extend(base)
361
-
362
- updates = [
363
- pl.DataFrame({"id": [0, 1, 2], "val": [100, 101, 102]}),
364
- pl.DataFrame({"id": [3, 4, 5], "val": [103, 104, 105]}),
365
- ]
378
+ repo.extend(pl.DataFrame({"id": list(range(20)), "val": list(range(20))}))
366
379
  with repo.get_replacing_df_writer(10) as writer:
367
- for df in updates:
368
- writer.add_to_batch(df)
369
-
380
+ writer.add_to_batch(pl.DataFrame({"id": [0, 1, 2], "val": [100, 101, 102]}))
381
+ writer.add_to_batch(pl.DataFrame({"id": [3, 4, 5], "val": [103, 104, 105]}))
370
382
  result = repo.get_full_df().sort("id")
371
383
  assert result.shape[0] == 20
372
- assert result.filter(pl.col("id") == 0)["val"][0] == 100
384
+ assert result.filter(pl.col("id") <= 5)["val"].to_list() == list(range(100, 106))
385
+ assert result.filter(pl.col("id") > 5)["val"].to_list() == list(range(6, 20))
373
386
 
374
387
 
375
388
  def test_df_writer_context_flushes(tmp_path):
@@ -384,27 +397,15 @@ def test_df_writer_context_flushes(tmp_path):
384
397
  # ---------------------------------------------------------------------------
385
398
 
386
399
 
387
- def test_concurrent_extend(tmp_path):
388
- repo = TableRepo(tmp_path / "data")
389
- n_threads = 8
390
- rows_per_thread = 100
391
-
392
- def worker(seed):
393
- repo.extend(make_df(rows_per_thread, seed=seed))
394
-
395
- threads = [threading.Thread(target=worker, args=(i,)) for i in range(n_threads)]
396
- for t in threads:
397
- t.start()
398
- for t in threads:
399
- t.join()
400
-
401
- assert repo.get_full_df().shape[0] == n_threads * rows_per_thread
402
-
403
-
404
- def test_concurrent_extend_partitioned(tmp_path):
405
- repo = TableRepo(tmp_path / "data", partition_cols=["C"])
406
- n_threads = 6
407
- rows_per_thread = 50
400
+ @pytest.mark.parametrize(
401
+ "partition_cols,n_threads,rows_per_thread",
402
+ [
403
+ ([], 8, 100),
404
+ (["C"], 6, 50),
405
+ ],
406
+ )
407
+ def test_concurrent_extend(tmp_path, partition_cols, n_threads, rows_per_thread):
408
+ repo = TableRepo(tmp_path / "data", partition_cols=partition_cols)
408
409
 
409
410
  def worker(seed):
410
411
  repo.extend(make_df(rows_per_thread, seed=seed))
@@ -414,7 +415,6 @@ def test_concurrent_extend_partitioned(tmp_path):
414
415
  t.start()
415
416
  for t in threads:
416
417
  t.join()
417
-
418
418
  assert repo.get_full_df().shape[0] == n_threads * rows_per_thread
419
419
 
420
420
 
@@ -426,11 +426,9 @@ def test_concurrent_extend_partitioned(tmp_path):
426
426
  def test_schema_mismatch_extend(tmp_path):
427
427
  repo = TableRepo(tmp_path / "data")
428
428
  repo.extend(pl.DataFrame({"A": [1, 2], "B": [3, 4]}))
429
- # Add column C that didn't exist before
430
429
  repo.extend(pl.DataFrame({"A": [5], "C": [6]}))
431
430
  full = repo.get_full_df()
432
431
  assert full.shape[0] == 3
433
- # B and C both present (nulls for missing values)
434
432
  assert "B" in full.columns
435
433
  assert "C" in full.columns
436
434
 
@@ -443,9 +441,6 @@ def test_schema_mismatch_extend(tmp_path):
443
441
  def test_empty_extend(tmp_path):
444
442
  repo = TableRepo(tmp_path / "data")
445
443
  repo.extend(pl.DataFrame({"A": pl.Series([], dtype=pl.Int64)}))
446
- # Empty extend should not create a file (nothing to write in _append_parts)
447
- # or create an empty file - behaviour: _extend_dir writes an empty file
448
- # The important thing is get_full_df works
449
444
  assert repo.get_full_df().shape[0] == 0
450
445
 
451
446
 
@@ -454,18 +449,17 @@ def test_empty_extend(tmp_path):
454
449
  # ---------------------------------------------------------------------------
455
450
 
456
451
 
457
- def test_lock_file_created(tmp_path):
458
- repo = TableRepo(tmp_path / "data")
459
- repo.extend(make_df(10))
460
- assert (tmp_path / "data" / ".lock").exists()
461
-
462
-
463
- def test_lock_file_created_partitioned(tmp_path):
464
- repo = TableRepo(tmp_path / "data", partition_cols=["C"])
465
- repo.extend(make_df(30))
466
- # Each partition dir should have a .lock file
467
- lock_files = list((tmp_path / "data").glob("**/.lock"))
468
- assert len(lock_files) == 3 # x, y, z partitions
452
+ @pytest.mark.parametrize(
453
+ "partition_cols,n,expected_locks",
454
+ [
455
+ ([], 10, 1),
456
+ (["C"], 30, 3), # x, y, z partitions
457
+ ],
458
+ )
459
+ def test_lock_file_created(tmp_path, partition_cols, n, expected_locks):
460
+ repo = TableRepo(tmp_path / "data", partition_cols=partition_cols)
461
+ repo.extend(make_df(n))
462
+ assert len(list((tmp_path / "data").glob("**/.lock"))) == expected_locks
469
463
 
470
464
 
471
465
  # ---------------------------------------------------------------------------
@@ -478,15 +472,15 @@ def test_map_partitions_basic(tmp_path):
478
472
  repo.extend(make_df(90))
479
473
  counts = repo.map_partitions(lambda df: len(df))
480
474
  assert sum(counts) == 90
481
- assert len(counts) == 3 # x, y, z
475
+ assert len(counts) == 3
482
476
 
483
477
 
484
478
  def test_map_partitions_parallel(tmp_path):
485
479
  repo = TableRepo(tmp_path / "data", partition_cols=["C"])
486
480
  repo.extend(make_df(90))
487
- counts_seq = sorted(repo.map_partitions(lambda df: len(df)))
488
- counts_par = sorted(repo.map_partitions(lambda df: len(df), workers=2))
489
- assert counts_seq == counts_par
481
+ assert sorted(repo.map_partitions(lambda df: len(df))) == sorted(
482
+ repo.map_partitions(lambda df: len(df), workers=2)
483
+ )
490
484
 
491
485
 
492
486
  def test_map_partitions_empty(tmp_path):
@@ -498,9 +492,7 @@ def test_map_partitions_no_partition_combines_files(tmp_path):
498
492
  repo = TableRepo(tmp_path / "data", max_records=5)
499
493
  repo.extend(make_df(15))
500
494
  assert repo.n_files == 3
501
- # map_partitions should combine all 3 files into one call
502
- result = repo.map_partitions(lambda df: df.shape[0])
503
- assert result == [15]
495
+ assert repo.map_partitions(lambda df: df.shape[0]) == [15]
504
496
 
505
497
 
506
498
  # ---------------------------------------------------------------------------
@@ -544,10 +536,75 @@ def test_compact_ipc(tmp_path):
544
536
 
545
537
 
546
538
  # ---------------------------------------------------------------------------
539
+ # purge_partition / replace_partition
540
+ # ---------------------------------------------------------------------------
541
+
542
+
543
+ def test_purge_partition(tmp_path):
544
+ repo = TableRepo(tmp_path / "data", partition_cols=["C"])
545
+ repo.extend(make_df(90))
546
+ x_count = repo.get_partition_df({"C": "x"}).shape[0]
547
+ repo.purge_partition({"C": "x"})
548
+ result = repo.get_full_df()
549
+ assert result.filter(pl.col("C") == "x").shape[0] == 0
550
+ assert result.shape[0] == 90 - x_count
551
+
552
+
553
+ def test_purge_partition_no_match(tmp_path):
554
+ repo = TableRepo(tmp_path / "data", partition_cols=["C"])
555
+ repo.extend(make_df(30))
556
+ n = repo.get_full_df().shape[0]
557
+ repo.purge_partition({"C": "nonexistent"})
558
+ assert repo.get_full_df().shape[0] == n
559
+
560
+
561
+ def test_replace_partition_basic(tmp_path):
562
+ repo = TableRepo(tmp_path / "data", partition_cols=["C"])
563
+ repo.extend(make_df(90))
564
+ repo.replace_partition(pl.DataFrame({"A": [9.0], "B": [5], "C": ["x"]}))
565
+ x_rows = repo.get_full_df().filter(pl.col("C") == "x")
566
+ assert x_rows.shape[0] == 1
567
+ assert x_rows["A"][0] == 9.0
568
+
569
+
570
+ def test_replace_partition_no_partition_raises(tmp_path):
571
+ repo = TableRepo(tmp_path / "data")
572
+ with pytest.raises(ValueError, match="partition_cols"):
573
+ repo.replace_partition(pl.DataFrame({"A": [1]}))
574
+
575
+
576
+ def test_replace_partition_with_max_records(tmp_path):
577
+ repo = TableRepo(tmp_path / "data", partition_cols=["C"], max_records=5)
578
+ repo.extend(make_df(90))
579
+ repo.replace_partition(
580
+ pl.DataFrame({"A": [1.0, 2.0], "B": [1, 2], "C": ["x", "x"]})
581
+ )
582
+ assert repo.get_full_df().filter(pl.col("C") == "x").shape[0] == 2
583
+
584
+
585
+ # ---------------------------------------------------------------------------
586
+ # get_partition_lf edge cases
587
+ # ---------------------------------------------------------------------------
588
+
589
+
590
+ def test_get_partition_lf_no_match(tmp_path):
591
+ repo = TableRepo(tmp_path / "data", partition_cols=["C"])
592
+ repo.extend(make_df(30))
593
+ assert repo.get_partition_lf({"C": "nonexistent"}).collect().shape[0] == 0
594
+
595
+
596
+ def test_get_partition_lf_no_partition_cols(tmp_path):
597
+ repo = TableRepo(tmp_path / "data")
598
+ repo.extend(make_df(10))
599
+ assert repo.get_partition_lf({}).collect().shape[0] == 0
600
+
601
+
602
+ # ---------------------------------------------------------------------------
603
+ # Performance smoke test
604
+ # ---------------------------------------------------------------------------
547
605
 
548
606
 
549
607
  def test_large_extend_performance(tmp_path):
550
- """Smoke test: 40 batches of 10k rows should complete without error."""
551
608
  repo = TableRepo(tmp_path / "data")
552
609
  rng = random.Random(7)
553
610
  for _ in range(40):
@@ -1,6 +0,0 @@
1
- | Name | Stmts | Miss | Cover |
2
- |------------------------------- | -------: | -------: | ------: |
3
- | shackleton/\_\_init\_\_.py | 3 | 0 | 100% |
4
- | shackleton/core.py | 259 | 3 | 99% |
5
- | shackleton/tests/test\_core.py | 324 | 1 | 99% |
6
- | **TOTAL** | **586** | **4** | **99%** |
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes