bucketbag 0.4.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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .venv-*/
5
+ venv/
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .ruff_cache/
10
+ .pytest_cache/
11
+ .DS_Store
12
+ *.parquet
@@ -0,0 +1,76 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0 — 2026-11-03
4
+
5
+ A rewrite of the internals around a written contract (CONTRACT.md), plus a run report for
6
+ agents. First release on PyPI: `pip install bucketbag`.
7
+
8
+ ### Breaking changes and how to migrate
9
+
10
+ - **`Bag` resume needs a done-suffix.** Write `to_bucket(out, done=".md")` when each input `k`
11
+ writes `k + ".md"`. An input is done only when that exact output exists, so `pages/p10.md`
12
+ no longer marks `pages/p1` done. `compute(resume=True)` without `done=` raises; pass
13
+ `resume=False` to process everything.
14
+ - **The map function is called as `fn(items)`, or `fn(items, ctx)` with `setup=`.** bucketbag
15
+ no longer inspects its signature, so a two-parameter `fn` without `setup=` now gets one
16
+ argument (before, it got `None` as the second).
17
+ - **`prefix=` joins below a prefix inside the bucket reference**, as a directory:
18
+ `iter_keys("ns/b/out", prefix="part-")` lists `out/part-*` (before, `prefix=` replaced it).
19
+ `prefix=""` adds nothing; before, it listed the whole bucket.
20
+ - **A trailing `/` in the bucket reference is kept**: `"ns/b/a/b/"` lists the directory
21
+ `a/b` only (before, it also matched `a/bc/`).
22
+ - **Globs follow fsspec's rules** (`HfFileSystem.glob`): `[...]` is a character class, `*` no
23
+ longer matches an empty path segment, and `**` inside a name (`**.jp2`) raises; write
24
+ `**/*.jp2`.
25
+ - **Settings that cannot work raise before anything is listed**: neither `n`/`batch_size` nor
26
+ `max_bytes` set, a value below 1, a negative `prefetch`, a bool, nan or inf.
27
+ - **`completed_keys` raises when a readable shard lacks `column=`** (before, every read error
28
+ became a warning, so a wrong column silently re-ran everything).
29
+ - **`batched_files(max_workers=)` is removed**; `prefetch` sets the number of download threads.
30
+ - **`LoadedItem` is frozen.** Assigning to `item.key` or `item.path` raises
31
+ `FrozenInstanceError`; build a new `LoadedItem(key=..., path=...)` instead.
32
+ - **Dependency floors:** `huggingface-hub>=1.21`, `fsspec>=2023.12`, `hf-xet>=1.5.2`.
33
+
34
+ ### New
35
+
36
+ - Hub web URLs work as bucket references (`https://huggingface.co/buckets/ns/b/tree/x/`).
37
+ - A run report: `BagStats` splits the time into download wait, map and upload; `bound_by`
38
+ names the slowest stage and `hint` the setting to change. `compute()` logs its settings,
39
+ a progress line after batches (at most every 30 s) and the report. bucketbag's log lines
40
+ print to stderr as `[bucketbag] …` until your script configures logging.
41
+ - A warning before the first download when the largest `prefetch + 1` batches would not fit in
42
+ the scratch dir (on `/dev/shm`, running out kills the process).
43
+ - `Bag.compute()` and `take()` warn once when an input wrote no `k + done` output (it would
44
+ rerun forever).
45
+ - `Bag.compute()` with nothing left to do returns without running `setup()` (no model load).
46
+ - `py.typed`: the package is fully typed.
47
+
48
+ ### Fixed
49
+
50
+ - `batched_files` kept `prefetch + 2` batches on disk; it now keeps at most `prefetch + 1`.
51
+ - Temp files no longer leak when Ctrl-C arrives during a download wait, or when a queued
52
+ download stored a `BaseException`.
53
+ - `Bag.take()` honours `max_bytes`.
54
+
55
+ ### Internal
56
+
57
+ - A pure core (`_core`) inside a thin I/O shell (`_hub`); one owner per temp dir; one run loop
58
+ for `take()` and `compute()`.
59
+ - CONTRACT.md states every rule; `tests/test_contract.py` checks each one against an in-memory
60
+ Hub that binds every call to the real `HfApi` signatures, plus Hypothesis fault fuzzing.
61
+ - CI: ruff format, ty on src and tests, a complexity limit, and a 15-minute job timeout.
62
+
63
+ ## 0.3.1 — 2026-08-26
64
+
65
+ - Fix: `prefix` over-matched sibling directories in `iter_keys`, `batched_files` and
66
+ `completed_keys`. `prefix="a/b/"` returned `a/bc/y` too. Two causes: bucketbag stripped the
67
+ trailing slash before calling the Hub, and the Hub's pagination `Link: next` URL carries a
68
+ literal `/` that a 302 removes, so pages after the first of a recursive listing match on the
69
+ slash-less string prefix. The slash is now sent verbatim and keys are re-filtered
70
+ client-side with the caller's original prefix: a trailing `/` means "this directory"; no
71
+ slash keeps string-prefix semantics.
72
+
73
+ ## 0.3.0
74
+
75
+ - `max_bytes` raises (not warns) when it cannot be honored (string keys carry no size).
76
+ - See git tags `v0.3.0` / `v0.2.0` for earlier release notes.
@@ -0,0 +1,108 @@
1
+ # bucketbag contract
2
+
3
+ What bucketbag promises. `tests/test_contract.py` checks each rule against an in-memory Hub
4
+ (`tests/fake_bucket.py`) that follows the Hub's own listing rules. A rule marked **gap** is one
5
+ the code breaks; its test is a strict xfail until the fix lands. No gap is open.
6
+
7
+ ## 1. Addressing
8
+
9
+ - A bucket reference is `ns/bucket[/prefix]`, `hf://buckets/ns/bucket[/prefix]` or a Hub web
10
+ URL (`https://huggingface.co/buckets/ns/bucket/tree/prefix`). Anything else raises
11
+ `ValueError`, including an `hf://` URI of a model or dataset.
12
+ - A prefix is a string prefix of the key, as on the Hub. A trailing `/` limits it to one
13
+ directory (`a/b/` lists `a/b/x`, not `a/bc/y`); without the slash, siblings match too.
14
+ The same rule holds for `prefix=` and for a prefix inside the bucket reference.
15
+ - `prefix=` joins below a prefix inside the reference, as directories: `ns/b/out` with
16
+ `prefix="part-"` lists `out/part-*`. `prefix=""` or `None` adds nothing.
17
+ - Globs (`include`, `exclude`) match the whole key from the bucket root, with fsspec's rules,
18
+ the ones `HfFileSystem.glob` uses: `*` stays inside one path segment, `**` as a whole segment
19
+ crosses `/`, `?` is one character and `[...]` a character class. `**` inside a name
20
+ (`**.jp2`) raises `ValueError`; write `**/*.jp2`. (`hf buckets sync --include` uses
21
+ `fnmatch`, where `*` also crosses `/`.)
22
+ - Listings return full keys from the bucket root, sorted, files only. `start_after` skips
23
+ keys up to and including it. `limit` keeps the first `limit` keys in the Hub's listing
24
+ order, which is lexical today but not documented (issue #9).
25
+ - `put_files(pairs, "ns/bucket/prefix")` writes each key under that prefix, joined as a
26
+ directory.
27
+
28
+ ## 2. Temporary files
29
+
30
+ `batched_files` deletes a batch's files when the loop moves past it. It also deletes them when
31
+ the loop body raises, when the caller stops early, and for batches downloaded ahead
32
+ (`prefetch`). `tests/test_batched_files.py` covers each `prefetch` level.
33
+
34
+ At most `prefetch + 1` batches are on disk at once: the one you are processing and `prefetch`
35
+ downloading ahead. `prefetch` is also the number of download threads (at least one). Downloads
36
+ always run in a thread, so Ctrl-C waits for the download in flight, then deletes its files.
37
+ A second Ctrl-C during that wait stops the clean-up, and the batches still downloading may
38
+ stay on disk.
39
+
40
+ "Stops early" means the generator is closed. A `for` loop over `batched_files(...)` closes it
41
+ when the loop ends. A caller that keeps the generator in a variable must call `.close()`
42
+ (or use `contextlib.closing`); until then the current and lookahead batches stay on disk.
43
+
44
+ ## 3. Resume (`Bag`)
45
+
46
+ - `to_bucket(out, done=".md")` names the output that marks an input done: an input `k` is done
47
+ when `k + ".md"` exists under `out` (`pages/p1.jp2` → `pages/p1.jp2.md`). The lookup is exact,
48
+ so no other output can mark `k` done (`pages/p10.md` never marks `pages/p1`). `compute()`
49
+ processes only inputs that are not done; `compute(resume=False)` processes all of them.
50
+ With nothing to do, `compute()` returns without running `setup()`.
51
+ - `compute(resume=True)` without `done=` raises `ValueError` before anything is listed.
52
+ - An input whose batch wrote no `k + done` output is logged as a warning, once per `compute()`
53
+ or `take()`. Resume cannot mark it done, so it runs again on every re-run.
54
+ - `take(n)` runs the plan on the first `n` inputs and writes nothing. `setup()` runs at most
55
+ once per `compute()` or `take()`.
56
+
57
+ ## 4. Failures (`Bag.compute`)
58
+
59
+ | What fails | What happens |
60
+ |---|---|
61
+ | The map function raises | `compute()` raises that error. Earlier batches' outputs stay. |
62
+ | A download fails | `compute()` raises that error. Earlier batches' outputs stay. |
63
+ | An upload fails | `compute()` raises that error. Outputs that landed before it count as done.* |
64
+
65
+ In every row the temporary files are deleted, and a re-run processes exactly the inputs whose
66
+ `done` output is missing. Hypothesis checks this over random mixes of failures (`tests/test_fault_fuzz.py`).
67
+
68
+ \* The server applies the files of an upload that succeed and reports the rest.
69
+ huggingface_hub 2.x raises `BucketBatchError` on that report. 1.x checked only the HTTP status,
70
+ so `compute()` went on, and the missing outputs were redone on the next run.
71
+
72
+ A file that is listed but gone by download time: with string keys
73
+ (`batched_files(keys=[...])`) the hub skips it with a warning, and bucketbag leaves it out of
74
+ the batch. `Bag` passes `BucketFile` objects, which the hub downloads by content hash without
75
+ an existence check. What happens then is not verified live; the hub source suggests the download
76
+ raises (`EntryNotFoundError`) at least when the gone file comes first in the batch.
77
+
78
+ ## 5. Arguments
79
+
80
+ - `token=` reaches every hub call.
81
+ - `batch_size` caps a batch's file count and `max_bytes` its total size; a single file larger
82
+ than `max_bytes` forms its own batch. `max_bytes` with string keys raises `ValueError`:
83
+ string keys carry no size.
84
+ - Settings that cannot work raise before anything is listed. `n` (`batch_size` in `Bag`) and
85
+ `prefetch` must be whole numbers; `max_bytes` may be any finite number, such as `4e9`. A bool,
86
+ nan or inf raises `TypeError`. At least one of `n` and `max_bytes` must be set, each at least
87
+ 1, and `prefetch` must be 0 or more (`ValueError`). `Bag.map_batches` checks its bounds when
88
+ the plan is built.
89
+ - The map function is called as `fn(items)`, or `fn(items, ctx)` when `setup=` is given. Its
90
+ signature is not inspected.
91
+ - `completed_keys` skips a shard it cannot read, with a warning. A readable shard without
92
+ `column` raises `ValueError`, so a wrong `column=` cannot make resume rerun everything.
93
+
94
+ ## 6. Run report (`Bag`)
95
+
96
+ - `compute()` returns `BagStats` with the time spent waiting for the next batch (`wait_s`: its
97
+ download, plus deleting the last batch's files), in the map function (`map_s`) and writing
98
+ outputs (`upload_s`). The three exclude `setup()` and
99
+ listing; `seconds` includes them. `bound_by` names the largest of the three and `hint` the
100
+ setting to change. When no batch ran, `bound_by` is `None` and `hint` is `""`.
101
+ `print(stats)` shows the split and the verdict.
102
+ - `compute()` logs its settings when it starts and its `BagStats` when it ends. `compute()` and
103
+ `take()` log a progress line after a batch, at most every 30 seconds: inputs done out of the
104
+ total, the rate, the time left and the split. A long batch delays its line. All go to the `bucketbag` logger at INFO, and print to stderr as
105
+ `[bucketbag] …` lines until the caller configures logging; then the caller's handlers get
106
+ them.
107
+ - `batched_files` warns before the first download when the largest `prefetch + 1` batches
108
+ would not fit in the scratch dir's free space. String keys carry no size, so they never warn.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel van Strien
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.5
2
+ Name: bucketbag
3
+ Version: 0.4.0
4
+ Summary: A minimal, toolz-style helper for batched, bounded-memory access to Hugging Face bucket files.
5
+ Project-URL: Homepage, https://github.com/davanstrien/bucketbag
6
+ Project-URL: Repository, https://github.com/davanstrien/bucketbag
7
+ Author: Daniel van Strien
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: batch,buckets,dataset,huggingface,toolz
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: fsspec>=2023.12
22
+ Requires-Dist: hf-xet>=1.5.2
23
+ Requires-Dist: huggingface-hub>=1.21
24
+ Requires-Dist: pillow
25
+ Requires-Dist: toolz
26
+ Description-Content-Type: text/markdown
27
+
28
+ # bucketbag
29
+
30
+ **Experimental.** A small, [toolz](https://toolz.readthedocs.io/)-style library for batch-processing
31
+ **large collections of files in [Hugging Face buckets](https://huggingface.co/docs/hub/en/storage-buckets)** — an experiment in what bucket-scale batch work
32
+ wants as an interface:
33
+
34
+ - **Bounded scratch** — download a batch, process, delete, repeat. Peak footprint ≈ `(prefetch + 1) × max_bytes`, never the corpus.
35
+ - **Resume** — re-runs skip work already done (long [HF Jobs](https://huggingface.co/docs/hub/en/jobs) can stop and restart).
36
+ - **Zero cleanup code** — temp files removed as the loop advances, even on exceptions.
37
+ - **Throughput knobs** — prefetch overlaps download with compute; `boost()` raises xet's small-file concurrency.
38
+
39
+ Mounting a bucket in a Job (`hf jobs run -v hf://buckets/...`) is the simplest way to use one; bucketbag explores the
40
+ **explicit API path** for batch workloads where footprint, resume, and concurrency should be under
41
+ your control. Not faster than the raw API (same transport), and not a framework.
42
+
43
+ ## Install
44
+
45
+ ```toml
46
+ # /// script
47
+ # dependencies = ["bucketbag==0.4.0"]
48
+ # ///
49
+ ```
50
+
51
+ Or `pip install bucketbag`. Pin a version: bucketbag is experimental and 0.x releases break.
52
+ [CHANGELOG.md](https://github.com/davanstrien/bucketbag/blob/main/CHANGELOG.md) says how to move from 0.3.
53
+
54
+ ## The read loop
55
+
56
+ ```python
57
+ from bucketbag import batched_files
58
+
59
+ for batch in batched_files("ns/my-bucket", include="**/*.jp2", max_bytes=4 * 2**30):
60
+ for it in batch: # LoadedItem, already on local disk
61
+ work(it.path) # or it.bytes / it.image / it.text() / it.json()
62
+ # ↑ this batch's files are deleted as the loop advances
63
+ ```
64
+
65
+ Default scratch dir is `/dev/shm` (**RAM tmpfs, not disk**) — size `(prefetch + 1) × max_bytes`
66
+ against memory, or pass `dir=`. One rule: don't keep a `LoadedItem`/`.path` past its batch.
67
+
68
+ ## Writing + resume
69
+
70
+ ```python
71
+ from bucketbag import iter_keys, batched_files, put_files
72
+
73
+ done = {k.removesuffix(".md") for k in iter_keys(OUT, include="**/*.md")}
74
+ keys = [k for k in iter_keys(SRC, include="**/*.jp2", objects=True) if k.path not in done]
75
+ for batch in batched_files(SRC, keys=keys, max_bytes=4 * 2**30):
76
+ put_files([(it.key + ".md", ocr(it)) for it in batch], OUT) # one API call per batch
77
+ ```
78
+
79
+ `iter_keys(..., objects=True)` yields `BucketFile` objects (with `.size`) instead of key
80
+ strings — pass those to `batched_files(keys=…, max_bytes=…)` so the byte bound can be honored.
81
+ Bare string keys carry no size, so **`max_bytes` + string keys raises `ValueError`** (fail fast
82
+ rather than silently run unbounded against the default RAM-tmpfs scratch — an OOMKill, not a
83
+ disk-full). Let `batched_files` do the listing (omit `keys=`) and it keeps the sizes for you.
84
+
85
+ Parquet outputs are your tool's job (polars / pyarrow straight to the bucket);
86
+ `completed_keys(OUT)` reads a done-set back from a `__source_key` column (pyarrow in *your* deps).
87
+
88
+ ## `Bag` — the loop as a lazy plan (experimental)
89
+
90
+ ```python
91
+ from bucketbag import Bag
92
+
93
+ bag = (Bag.from_bucket(SRC, include="**/*.jp2")
94
+ .map_batches(ocr_batch, batch_size=32, setup=load_model) # fn(items[, ctx]) -> [(key, content)]
95
+ .to_bucket(OUT, done=".md")) # input k is done once k + ".md" exists
96
+ bag.take(3) # dev peek: outputs only, nothing written
97
+ stats = bag.compute() # list -> skip done -> download -> fn -> put_files; kill + re-run safe
98
+ print(stats) # for example:
99
+ # 476 processed (+24 skipped as done) in 15 batches -> 952 outputs, 3712.4s
100
+ # time: download wait 1%, map 98%, upload 1%; bound by map. The map function is the limit: ...
101
+ ```
102
+
103
+ `stats.bound_by` (`"download"`, `"map"` or `"upload"`) and `stats.hint` tell a person or an
104
+ agent what to tune next. `compute()` also logs its settings, a progress line after batches
105
+ (at most every 30 s) and the same report at the end. They print to stderr as `[bucketbag] …`
106
+ lines, so they show up in `hf jobs logs`, until your script configures logging; then your
107
+ handlers get them.
108
+
109
+ Resume: `done=".md"` names the output that marks an input done, and the lookup is exact
110
+ (`pages/p10.md` never marks `pages/p1` done). `compute()` runs in-process today; fanning the same
111
+ plan across HF Jobs (one shard per job) is the next layer.
112
+
113
+ To follow a bucket that another job is still filling, re-run the plan. Each pass lists both
114
+ buckets again and skips what is done; `setup()` runs only when a pass has work:
115
+
116
+ ```python
117
+ import time
118
+
119
+ while producer_running():
120
+ bag.compute()
121
+ time.sleep(60)
122
+ bag.compute() # once more: files may have landed during the last sleep
123
+ ```
124
+
125
+ [CONTRACT.md](https://github.com/davanstrien/bucketbag/blob/main/CONTRACT.md) states what bucketbag promises; the tests check each rule.
126
+
127
+ ## API
128
+
129
+ | | |
130
+ | --- | --- |
131
+ | `batched_files(bucket, *, keys, include, exclude, n=20, max_bytes, dir, prefetch=2, ...)` | download batches → yield `list[LoadedItem]` → auto-delete |
132
+ | `iter_keys(bucket, *, prefix, include, exclude, start_after, limit, token)` | list + glob-filter + sort keys (no download) |
133
+ | `put_files(pairs, out_bucket)` / `put_bytes` / `put_text` | write raw objects; `put_files` batches many per call |
134
+ | `completed_keys(out_bucket, *, prefix, column="__source_key")` | done-set from parquet outputs |
135
+ | `boost(*, file_concurrency=32)` | raise xet download concurrency (~2.5× on small files) |
136
+ | `LoadedItem` | `.key` `.path` + lazy `.bytes` `.image` `.text()` `.json()` |
137
+ | `Bag.from_bucket(...).map_batches(fn).to_bucket(out, done=".md")` + `.take(n)` / `.compute()` | the loop as a lazy plan |
138
+
139
+ `bucket` = `"ns/bucket"`, `"ns/bucket/prefix"`, `"hf://buckets/ns/bucket/prefix"`, or a Hub web URL.
140
+ `prefix=` joins below a prefix inside `bucket`, as a directory.
141
+ Globs follow fsspec's rules (as in `HfFileSystem.glob`): `*` within a path segment, `**` across `/`.
142
+ `prefix` is a string prefix and the trailing slash matters: `prefix="a/b/"` lists the
143
+ directory `a/b` only; `prefix="a/b"` also matches siblings like `a/bc/y`. The prefix is sent
144
+ verbatim to the Hub. Bucketbag also checks each returned key against it as a cheap safeguard
145
+ against over-matching listings, including pagination regressions.
146
+
147
+ ## Performance
148
+
149
+ Cold + disjoint, replicated (`examples/bench.py`, l4x1, ~0.8 MB jp2; ±10–20%):
150
+
151
+ | | default xet | + `boost()` |
152
+ | --- | --- | --- |
153
+ | `bucketbag` (prefetch 2–4) | ~85–110 img/s | **~200–270 img/s** |
154
+ | raw `download_bucket_files` | ~85–105 | ~210–260 |
155
+ | `HfFileSystem` (32 threads) | ~90–110 | ~110 (bypasses xet) |
156
+ | FUSE mount (same workload) | ~22 | — |
157
+
158
+ Competitive with the raw API by design; the win is the interface. The one real throughput lever is
159
+ xet's concurrent-file cap (default 8) — `boost()` raises it, ~2.5× on small files. Skip it for
160
+ large files (over-subscription).
161
+
162
+ ### Decode, not transport
163
+
164
+ If a pipeline is slow, time the download and the decode separately before tuning transport
165
+ (`examples/decode_probe.py` does this). The transport is usually not the gate: on ~0.7 MB page
166
+ images, `batched_files` moves 40 files in ~1 s, and decoding those 40 takes ~70 s. Three things
167
+ to check in the decode step:
168
+
169
+ - **Threads may not help.** Some Pillow decoders hold the GIL (JPEG 2000 does): 8 threads gave the
170
+ same speed as 1. A process pool did scale.
171
+ - **Decode smaller.** Formats with resolution levels (JPEG 2000: `im.reduce`; JPEG: `im.draft()`)
172
+ can decode at 1/4 or 1/8 the pixels for a fraction of the time — usually all a classifier needs.
173
+ - **Check the reduced size loads.** Pillow's JPEG 2000 `reduce>=2` fails with "broken data stream"
174
+ when its size rounding disagrees with OpenJPEG's; `safe_reduce()` in the example picks the
175
+ largest level that loads.
176
+
177
+ ## Not in scope
178
+
179
+ DAGs, shuffles, custom formats, return values to a driver. Stages couple through bucket keys; a
180
+ global reduce is a one-off polars job. Jobs fan-out is planned as a thin layer on `Bag`.
@@ -0,0 +1,153 @@
1
+ # bucketbag
2
+
3
+ **Experimental.** A small, [toolz](https://toolz.readthedocs.io/)-style library for batch-processing
4
+ **large collections of files in [Hugging Face buckets](https://huggingface.co/docs/hub/en/storage-buckets)** — an experiment in what bucket-scale batch work
5
+ wants as an interface:
6
+
7
+ - **Bounded scratch** — download a batch, process, delete, repeat. Peak footprint ≈ `(prefetch + 1) × max_bytes`, never the corpus.
8
+ - **Resume** — re-runs skip work already done (long [HF Jobs](https://huggingface.co/docs/hub/en/jobs) can stop and restart).
9
+ - **Zero cleanup code** — temp files removed as the loop advances, even on exceptions.
10
+ - **Throughput knobs** — prefetch overlaps download with compute; `boost()` raises xet's small-file concurrency.
11
+
12
+ Mounting a bucket in a Job (`hf jobs run -v hf://buckets/...`) is the simplest way to use one; bucketbag explores the
13
+ **explicit API path** for batch workloads where footprint, resume, and concurrency should be under
14
+ your control. Not faster than the raw API (same transport), and not a framework.
15
+
16
+ ## Install
17
+
18
+ ```toml
19
+ # /// script
20
+ # dependencies = ["bucketbag==0.4.0"]
21
+ # ///
22
+ ```
23
+
24
+ Or `pip install bucketbag`. Pin a version: bucketbag is experimental and 0.x releases break.
25
+ [CHANGELOG.md](https://github.com/davanstrien/bucketbag/blob/main/CHANGELOG.md) says how to move from 0.3.
26
+
27
+ ## The read loop
28
+
29
+ ```python
30
+ from bucketbag import batched_files
31
+
32
+ for batch in batched_files("ns/my-bucket", include="**/*.jp2", max_bytes=4 * 2**30):
33
+ for it in batch: # LoadedItem, already on local disk
34
+ work(it.path) # or it.bytes / it.image / it.text() / it.json()
35
+ # ↑ this batch's files are deleted as the loop advances
36
+ ```
37
+
38
+ Default scratch dir is `/dev/shm` (**RAM tmpfs, not disk**) — size `(prefetch + 1) × max_bytes`
39
+ against memory, or pass `dir=`. One rule: don't keep a `LoadedItem`/`.path` past its batch.
40
+
41
+ ## Writing + resume
42
+
43
+ ```python
44
+ from bucketbag import iter_keys, batched_files, put_files
45
+
46
+ done = {k.removesuffix(".md") for k in iter_keys(OUT, include="**/*.md")}
47
+ keys = [k for k in iter_keys(SRC, include="**/*.jp2", objects=True) if k.path not in done]
48
+ for batch in batched_files(SRC, keys=keys, max_bytes=4 * 2**30):
49
+ put_files([(it.key + ".md", ocr(it)) for it in batch], OUT) # one API call per batch
50
+ ```
51
+
52
+ `iter_keys(..., objects=True)` yields `BucketFile` objects (with `.size`) instead of key
53
+ strings — pass those to `batched_files(keys=…, max_bytes=…)` so the byte bound can be honored.
54
+ Bare string keys carry no size, so **`max_bytes` + string keys raises `ValueError`** (fail fast
55
+ rather than silently run unbounded against the default RAM-tmpfs scratch — an OOMKill, not a
56
+ disk-full). Let `batched_files` do the listing (omit `keys=`) and it keeps the sizes for you.
57
+
58
+ Parquet outputs are your tool's job (polars / pyarrow straight to the bucket);
59
+ `completed_keys(OUT)` reads a done-set back from a `__source_key` column (pyarrow in *your* deps).
60
+
61
+ ## `Bag` — the loop as a lazy plan (experimental)
62
+
63
+ ```python
64
+ from bucketbag import Bag
65
+
66
+ bag = (Bag.from_bucket(SRC, include="**/*.jp2")
67
+ .map_batches(ocr_batch, batch_size=32, setup=load_model) # fn(items[, ctx]) -> [(key, content)]
68
+ .to_bucket(OUT, done=".md")) # input k is done once k + ".md" exists
69
+ bag.take(3) # dev peek: outputs only, nothing written
70
+ stats = bag.compute() # list -> skip done -> download -> fn -> put_files; kill + re-run safe
71
+ print(stats) # for example:
72
+ # 476 processed (+24 skipped as done) in 15 batches -> 952 outputs, 3712.4s
73
+ # time: download wait 1%, map 98%, upload 1%; bound by map. The map function is the limit: ...
74
+ ```
75
+
76
+ `stats.bound_by` (`"download"`, `"map"` or `"upload"`) and `stats.hint` tell a person or an
77
+ agent what to tune next. `compute()` also logs its settings, a progress line after batches
78
+ (at most every 30 s) and the same report at the end. They print to stderr as `[bucketbag] …`
79
+ lines, so they show up in `hf jobs logs`, until your script configures logging; then your
80
+ handlers get them.
81
+
82
+ Resume: `done=".md"` names the output that marks an input done, and the lookup is exact
83
+ (`pages/p10.md` never marks `pages/p1` done). `compute()` runs in-process today; fanning the same
84
+ plan across HF Jobs (one shard per job) is the next layer.
85
+
86
+ To follow a bucket that another job is still filling, re-run the plan. Each pass lists both
87
+ buckets again and skips what is done; `setup()` runs only when a pass has work:
88
+
89
+ ```python
90
+ import time
91
+
92
+ while producer_running():
93
+ bag.compute()
94
+ time.sleep(60)
95
+ bag.compute() # once more: files may have landed during the last sleep
96
+ ```
97
+
98
+ [CONTRACT.md](https://github.com/davanstrien/bucketbag/blob/main/CONTRACT.md) states what bucketbag promises; the tests check each rule.
99
+
100
+ ## API
101
+
102
+ | | |
103
+ | --- | --- |
104
+ | `batched_files(bucket, *, keys, include, exclude, n=20, max_bytes, dir, prefetch=2, ...)` | download batches → yield `list[LoadedItem]` → auto-delete |
105
+ | `iter_keys(bucket, *, prefix, include, exclude, start_after, limit, token)` | list + glob-filter + sort keys (no download) |
106
+ | `put_files(pairs, out_bucket)` / `put_bytes` / `put_text` | write raw objects; `put_files` batches many per call |
107
+ | `completed_keys(out_bucket, *, prefix, column="__source_key")` | done-set from parquet outputs |
108
+ | `boost(*, file_concurrency=32)` | raise xet download concurrency (~2.5× on small files) |
109
+ | `LoadedItem` | `.key` `.path` + lazy `.bytes` `.image` `.text()` `.json()` |
110
+ | `Bag.from_bucket(...).map_batches(fn).to_bucket(out, done=".md")` + `.take(n)` / `.compute()` | the loop as a lazy plan |
111
+
112
+ `bucket` = `"ns/bucket"`, `"ns/bucket/prefix"`, `"hf://buckets/ns/bucket/prefix"`, or a Hub web URL.
113
+ `prefix=` joins below a prefix inside `bucket`, as a directory.
114
+ Globs follow fsspec's rules (as in `HfFileSystem.glob`): `*` within a path segment, `**` across `/`.
115
+ `prefix` is a string prefix and the trailing slash matters: `prefix="a/b/"` lists the
116
+ directory `a/b` only; `prefix="a/b"` also matches siblings like `a/bc/y`. The prefix is sent
117
+ verbatim to the Hub. Bucketbag also checks each returned key against it as a cheap safeguard
118
+ against over-matching listings, including pagination regressions.
119
+
120
+ ## Performance
121
+
122
+ Cold + disjoint, replicated (`examples/bench.py`, l4x1, ~0.8 MB jp2; ±10–20%):
123
+
124
+ | | default xet | + `boost()` |
125
+ | --- | --- | --- |
126
+ | `bucketbag` (prefetch 2–4) | ~85–110 img/s | **~200–270 img/s** |
127
+ | raw `download_bucket_files` | ~85–105 | ~210–260 |
128
+ | `HfFileSystem` (32 threads) | ~90–110 | ~110 (bypasses xet) |
129
+ | FUSE mount (same workload) | ~22 | — |
130
+
131
+ Competitive with the raw API by design; the win is the interface. The one real throughput lever is
132
+ xet's concurrent-file cap (default 8) — `boost()` raises it, ~2.5× on small files. Skip it for
133
+ large files (over-subscription).
134
+
135
+ ### Decode, not transport
136
+
137
+ If a pipeline is slow, time the download and the decode separately before tuning transport
138
+ (`examples/decode_probe.py` does this). The transport is usually not the gate: on ~0.7 MB page
139
+ images, `batched_files` moves 40 files in ~1 s, and decoding those 40 takes ~70 s. Three things
140
+ to check in the decode step:
141
+
142
+ - **Threads may not help.** Some Pillow decoders hold the GIL (JPEG 2000 does): 8 threads gave the
143
+ same speed as 1. A process pool did scale.
144
+ - **Decode smaller.** Formats with resolution levels (JPEG 2000: `im.reduce`; JPEG: `im.draft()`)
145
+ can decode at 1/4 or 1/8 the pixels for a fraction of the time — usually all a classifier needs.
146
+ - **Check the reduced size loads.** Pillow's JPEG 2000 `reduce>=2` fails with "broken data stream"
147
+ when its size rounding disagrees with OpenJPEG's; `safe_reduce()` in the example picks the
148
+ largest level that loads.
149
+
150
+ ## Not in scope
151
+
152
+ DAGs, shuffles, custom formats, return values to a driver. Stages couple through bucket keys; a
153
+ global reduce is a one-off polars job. Jobs fan-out is planned as a thin layer on `Bag`.
@@ -0,0 +1,107 @@
1
+ [project]
2
+ name = "bucketbag"
3
+ version = "0.4.0"
4
+ description = "A minimal, toolz-style helper for batched, bounded-memory access to Hugging Face bucket files."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [{ name = "Daniel van Strien" }]
9
+ keywords = ["huggingface", "buckets", "batch", "toolz", "dataset"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Intended Audience :: Science/Research",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ # 1.21: parse_hf_uri reads Hub web URLs (1.13 rejects them).
23
+ "huggingface-hub>=1.21",
24
+ "hf-xet>=1.5.2",
25
+ # 2023.12: fsspec.utils.glob_translate, the glob rules HfFileSystem.glob uses.
26
+ "fsspec>=2023.12",
27
+ "toolz",
28
+ "pillow",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/davanstrien/bucketbag"
33
+ Repository = "https://github.com/davanstrien/bucketbag"
34
+
35
+ [build-system]
36
+ requires = ["hatchling"]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/bucketbag"]
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ # Only what a source build and its tests need; never local venvs or experiment outputs.
44
+ include = [
45
+ "/src",
46
+ "/tests",
47
+ "/README.md",
48
+ "/CHANGELOG.md",
49
+ "/CONTRACT.md",
50
+ "/LICENSE",
51
+ "/pyproject.toml",
52
+ ]
53
+
54
+ [tool.ruff]
55
+ line-length = 100
56
+
57
+ [tool.ruff.lint]
58
+ # ANN: the package and its tests are fully typed. C90/PLR09: a complexity ratchet; no function
59
+ # may grow past these limits.
60
+ select = ["E", "F", "I", "UP", "B", "ANN", "C90", "PLR0912", "PLR0915"]
61
+ # Any is deliberate where the type is the caller's (LoadedItem.json(), setup() results).
62
+ ignore = ["ANN401"]
63
+
64
+ [tool.ruff.lint.mccabe]
65
+ max-complexity = 10
66
+
67
+ [tool.ruff.lint.per-file-ignores]
68
+ # Scripts, not library code: examples measure things, experiments vendor recipe code unchanged.
69
+ "examples/**" = ["ANN", "C90", "PLR"]
70
+ "experiments/**" = ["B905", "ANN", "C90", "PLR"]
71
+
72
+ [tool.ty.src]
73
+ include = ["src", "tests"]
74
+
75
+ [tool.ty.analysis]
76
+ # pyarrow is an optional import in completed_keys, not a bucketbag dependency.
77
+ allowed-unresolved-imports = ["pyarrow.**"]
78
+
79
+ [tool.pytest.ini_options]
80
+ testpaths = ["tests"]
81
+ addopts = ["-ra", "--strict-markers", "--strict-config"]
82
+ # A test stuck for 120 s dumps every thread's stack and ends the run. The thread method is
83
+ # needed: with the signal method a stuck prefetch worker still blocks the process from exiting.
84
+ timeout = 120
85
+ timeout_method = "thread"
86
+
87
+ [dependency-groups]
88
+ dev = [
89
+ "hypothesis>=6.155.7",
90
+ "pytest>=9.1.1",
91
+ "pytest-timeout>=2.4",
92
+ "ruff>=0.15",
93
+ "ty>=0.0.91",
94
+ ]
95
+
96
+ # Pin the index to public PyPI so the lockfile resolves against the canonical registry,
97
+ # independent of any ambient uv config (e.g. a machine-local index-url). This keeps
98
+ # `uv sync` reproducible for CI and other contributors.
99
+ #
100
+ # Same class of problem, second instance: regenerate the lock with `uv lock --no-config`.
101
+ # A machine-global `exclude-newer` otherwise bakes an `[options]` block into uv.lock that
102
+ # CI (with no such config) rejects under `--locked`. Locally, prefer
103
+ # `uv sync --frozen` / `uv run --no-sync` to avoid churning the lock back.
104
+ [[tool.uv.index]]
105
+ name = "pypi"
106
+ url = "https://pypi.org/simple"
107
+ default = true