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.
- shackleton-2.0.1/.github/workflows/release.yml +27 -0
- {shackleton-1.2.0 → shackleton-2.0.1}/.github/workflows/test.yml +3 -2
- {shackleton-1.2.0 → shackleton-2.0.1}/.gitignore +2 -12
- {shackleton-1.2.0 → shackleton-2.0.1}/PKG-INFO +9 -11
- {shackleton-1.2.0 → shackleton-2.0.1}/README.md +6 -8
- shackleton-2.0.1/docs/release_notes.md +29 -0
- {shackleton-1.2.0 → shackleton-2.0.1}/pyproject.toml +1 -1
- shackleton-2.0.1/shackleton/__init__.py +13 -0
- {shackleton-1.2.0 → shackleton-2.0.1}/shackleton/core.py +150 -64
- {shackleton-1.2.0 → shackleton-2.0.1}/shackleton/tests/test_core.py +72 -2
- {shackleton-1.2.0 → shackleton-2.0.1}/uv.lock +14 -14
- shackleton-1.2.0/.github/workflows/twine_release.yml +0 -15
- shackleton-1.2.0/docs/release_notes.md +0 -9
- shackleton-1.2.0/shackleton/__init__.py +0 -7
- {shackleton-1.2.0 → shackleton-2.0.1}/LICENSE +0 -0
- {shackleton-1.2.0 → shackleton-2.0.1}/PERF.md +0 -0
- {shackleton-1.2.0 → shackleton-2.0.1}/bench.py +0 -0
- {shackleton-1.2.0 → shackleton-2.0.1}/coverage/report.md +0 -0
- {shackleton-1.2.0 → shackleton-2.0.1}/docs/api.md +0 -0
- {shackleton-1.2.0 → shackleton-2.0.1}/plans.md +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
gate:
|
|
10
|
+
# compatibility_test.yml here also requires the OS matrix to pass
|
|
11
|
+
uses: ./.github/workflows/test.yml
|
|
12
|
+
release:
|
|
13
|
+
needs: gate
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
permissions:
|
|
16
|
+
contents: write
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v7
|
|
19
|
+
with:
|
|
20
|
+
fetch-depth: 0
|
|
21
|
+
- uses: astral-sh/setup-uv@v10.3.0
|
|
22
|
+
- run: uv build && uv publish
|
|
23
|
+
env:
|
|
24
|
+
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
|
25
|
+
- run: gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --notes "$(git tag -l --format='%(contents)' "$GITHUB_REF_NAME")"
|
|
26
|
+
env:
|
|
27
|
+
GH_TOKEN: ${{ github.token }}
|
|
@@ -7,13 +7,14 @@ on:
|
|
|
7
7
|
push:
|
|
8
8
|
branches:
|
|
9
9
|
- main
|
|
10
|
+
workflow_call:
|
|
10
11
|
|
|
11
12
|
jobs:
|
|
12
13
|
test:
|
|
13
14
|
runs-on: ubuntu-latest
|
|
14
15
|
steps:
|
|
15
|
-
- uses: actions/checkout@
|
|
16
|
-
- uses: astral-sh/setup-uv@
|
|
16
|
+
- uses: actions/checkout@v7
|
|
17
|
+
- uses: astral-sh/setup-uv@v10.3.0
|
|
17
18
|
with:
|
|
18
19
|
python-version: '3.12'
|
|
19
20
|
enable-cache: true
|
|
@@ -68,19 +68,12 @@ instance/
|
|
|
68
68
|
# Scrapy stuff:
|
|
69
69
|
.scrapy
|
|
70
70
|
|
|
71
|
-
#
|
|
72
|
-
|
|
71
|
+
# Generated coverage HTML (report.md is committed, HTML is not)
|
|
72
|
+
coverage/html/
|
|
73
73
|
|
|
74
74
|
# PyBuilder
|
|
75
75
|
target/
|
|
76
76
|
|
|
77
|
-
# Jupyter Notebook
|
|
78
|
-
.ipynb_checkpoints
|
|
79
|
-
|
|
80
|
-
# IPython
|
|
81
|
-
profile_default/
|
|
82
|
-
ipython_config.py
|
|
83
|
-
|
|
84
77
|
# pyenv
|
|
85
78
|
.python-version
|
|
86
79
|
|
|
@@ -113,9 +106,6 @@ venv.bak/
|
|
|
113
106
|
# Rope project settings
|
|
114
107
|
.ropeproject
|
|
115
108
|
|
|
116
|
-
# mkdocs documentation
|
|
117
|
-
/site
|
|
118
|
-
|
|
119
109
|
# mypy
|
|
120
110
|
.mypy_cache/
|
|
121
111
|
.dmypy.json
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: shackleton
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.1
|
|
4
4
|
Summary: Polars-based persistent tabular store backed by Parquet or Arrow IPC files
|
|
5
5
|
Project-URL: Homepage, https://github.com/endremborza/shackleton
|
|
6
6
|
Author-email: Endre Márk Borza <endremborza@gmail.com>
|
|
@@ -13,7 +13,7 @@ License: Copyright 2023 Endre Márk Borza
|
|
|
13
13
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
14
14
|
License-File: LICENSE
|
|
15
15
|
Requires-Python: >=3.12
|
|
16
|
-
Requires-Dist: polars
|
|
16
|
+
Requires-Dist: polars>=2
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
|
|
19
19
|
# shackleton
|
|
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
|
|
23
23
|
Persistent tabular store backed by Parquet or Arrow IPC, built on [polars](https://pola.rs).
|
|
24
24
|
|
|
25
|
-
- **Partitioned writes** — by column value or
|
|
25
|
+
- **Partitioned writes** — by column value or hash bucket
|
|
26
26
|
- **Sorted merge** — maintain sorted order on extend via `id_col`
|
|
27
27
|
- **Lazy reads** — returns `pl.LazyFrame` so polars can push down predicates
|
|
28
28
|
- **Schema evolution** — diagonal concat handles mismatched columns transparently
|
|
@@ -91,7 +91,7 @@ df_eu = repo.get_partition_df({"region": "eu"})
|
|
|
91
91
|
## Hash partitioning
|
|
92
92
|
|
|
93
93
|
When partition values have unbounded cardinality, hash into fixed buckets instead.
|
|
94
|
-
Uses polars'
|
|
94
|
+
Uses polars' `Series.hash` — vectorised, GIL-free. That hash is not stable across polars versions while bucket assignment lives on disk, so before its first bucket-addressed operation (a write, `get_partition_*`, `purge_partition`) a repo samples stored rows and raises `HashDriftError` if they no longer hash to their bucket. `repo.rehash()` moves every row into its current bucket.
|
|
95
95
|
|
|
96
96
|
```python
|
|
97
97
|
from shackleton import HashPartitioner
|
|
@@ -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
|
|
117
|
-
|
|
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 —
|
|
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
|
|
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
|
|
7
|
+
- **Partitioned writes** — by column value or hash bucket
|
|
8
8
|
- **Sorted merge** — maintain sorted order on extend via `id_col`
|
|
9
9
|
- **Lazy reads** — returns `pl.LazyFrame` so polars can push down predicates
|
|
10
10
|
- **Schema evolution** — diagonal concat handles mismatched columns transparently
|
|
@@ -73,7 +73,7 @@ df_eu = repo.get_partition_df({"region": "eu"})
|
|
|
73
73
|
## Hash partitioning
|
|
74
74
|
|
|
75
75
|
When partition values have unbounded cardinality, hash into fixed buckets instead.
|
|
76
|
-
Uses polars'
|
|
76
|
+
Uses polars' `Series.hash` — vectorised, GIL-free. That hash is not stable across polars versions while bucket assignment lives on disk, so before its first bucket-addressed operation (a write, `get_partition_*`, `purge_partition`) a repo samples stored rows and raises `HashDriftError` if they no longer hash to their bucket. `repo.rehash()` moves every row into its current bucket.
|
|
77
77
|
|
|
78
78
|
```python
|
|
79
79
|
from shackleton import HashPartitioner
|
|
@@ -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
|
|
99
|
-
|
|
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 —
|
|
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
|
|
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'
|
|
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
|
|
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
|
|
83
|
-
when ``
|
|
84
|
-
``
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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) ->
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
302
|
-
|
|
303
|
-
|
|
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.
|
|
307
|
-
out = old.merge_sorted(
|
|
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
|
-
#
|
|
312
|
-
#
|
|
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.
|
|
316
|
-
maintain_order=bool(self.
|
|
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(
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
.
|
|
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
|
-
|
|
334
|
-
if
|
|
335
|
-
|
|
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
|
|
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.
|
|
357
|
-
out = out.sort(self.
|
|
435
|
+
if self.id_cols:
|
|
436
|
+
out = out.sort(self.id_cols, maintain_order=True)
|
|
437
|
+
self._write(out, path)
|
|
358
438
|
else:
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
431
|
-
return [self.
|
|
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]
|
|
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 = "
|
|
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/
|
|
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/
|
|
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 = "
|
|
55
|
+
version = "2.0.0"
|
|
56
56
|
source = { registry = "https://pypi.org/simple" }
|
|
57
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
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/
|
|
60
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
61
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
62
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
63
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
64
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
65
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
66
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
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 }}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|