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.
- {shackleton-1.1.0 → shackleton-1.2.0}/PKG-INFO +58 -3
- {shackleton-1.1.0 → shackleton-1.2.0}/README.md +57 -2
- shackleton-1.2.0/coverage/report.md +6 -0
- {shackleton-1.1.0 → shackleton-1.2.0}/docs/release_notes.md +6 -0
- {shackleton-1.1.0 → shackleton-1.2.0}/shackleton/__init__.py +1 -1
- {shackleton-1.1.0 → shackleton-1.2.0}/shackleton/core.py +63 -8
- {shackleton-1.1.0 → shackleton-1.2.0}/shackleton/tests/test_core.py +217 -160
- shackleton-1.1.0/coverage/report.md +0 -6
- {shackleton-1.1.0 → shackleton-1.2.0}/.github/workflows/test.yml +0 -0
- {shackleton-1.1.0 → shackleton-1.2.0}/.github/workflows/twine_release.yml +0 -0
- {shackleton-1.1.0 → shackleton-1.2.0}/.gitignore +0 -0
- {shackleton-1.1.0 → shackleton-1.2.0}/LICENSE +0 -0
- {shackleton-1.1.0 → shackleton-1.2.0}/PERF.md +0 -0
- {shackleton-1.1.0 → shackleton-1.2.0}/bench.py +0 -0
- {shackleton-1.1.0 → shackleton-1.2.0}/docs/api.md +0 -0
- {shackleton-1.1.0 → shackleton-1.2.0}/plans.md +0 -0
- {shackleton-1.1.0 → shackleton-1.2.0}/pyproject.toml +0 -0
- {shackleton-1.1.0 → shackleton-1.2.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shackleton
|
|
3
|
-
Version: 1.
|
|
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
|
|
116
|
-
`replace_records` does an upsert: matching IDs
|
|
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
|
|
98
|
-
`replace_records` does an upsert: matching IDs
|
|
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%** |
|
|
@@ -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
|
|
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
|
-
|
|
295
|
+
kw: dict = {}
|
|
269
296
|
if self.compression:
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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
|
|
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
|
|
11
|
+
def make_df(n: int, seed: int = 0) -> pl.DataFrame:
|
|
16
12
|
rng = random.Random(seed)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
46
|
-
repo.
|
|
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 →
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
repo.extend(
|
|
115
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
155
|
+
def test_replace_records_insert_and_update(tmp_path):
|
|
176
156
|
repo = TableRepo(tmp_path / "data", id_col="id")
|
|
177
|
-
|
|
178
|
-
repo.
|
|
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
|
-
|
|
197
|
-
repo.
|
|
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
|
-
|
|
209
|
-
repo.
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
206
|
+
# dedup_cols
|
|
230
207
|
# ---------------------------------------------------------------------------
|
|
231
208
|
|
|
232
209
|
|
|
233
|
-
def
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
268
|
-
repo.
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
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):
|
|
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
|
|
353
|
-
writer.add_to_batch(
|
|
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
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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")
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
repo
|
|
466
|
-
|
|
467
|
-
|
|
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
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|