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.
- bucketbag-0.4.0/.gitignore +12 -0
- bucketbag-0.4.0/CHANGELOG.md +76 -0
- bucketbag-0.4.0/CONTRACT.md +108 -0
- bucketbag-0.4.0/LICENSE +21 -0
- bucketbag-0.4.0/PKG-INFO +180 -0
- bucketbag-0.4.0/README.md +153 -0
- bucketbag-0.4.0/pyproject.toml +107 -0
- bucketbag-0.4.0/src/bucketbag/__init__.py +70 -0
- bucketbag-0.4.0/src/bucketbag/_core.py +209 -0
- bucketbag-0.4.0/src/bucketbag/_hub.py +517 -0
- bucketbag-0.4.0/src/bucketbag/_log.py +50 -0
- bucketbag-0.4.0/src/bucketbag/bag.py +388 -0
- bucketbag-0.4.0/src/bucketbag/py.typed +0 -0
- bucketbag-0.4.0/tests/conftest.py +108 -0
- bucketbag-0.4.0/tests/fake_bucket.py +236 -0
- bucketbag-0.4.0/tests/test_bag.py +148 -0
- bucketbag-0.4.0/tests/test_batched_files.py +178 -0
- bucketbag-0.4.0/tests/test_bucket_ref.py +83 -0
- bucketbag-0.4.0/tests/test_contract.py +626 -0
- bucketbag-0.4.0/tests/test_fault_fuzz.py +106 -0
- bucketbag-0.4.0/tests/test_glob.py +103 -0
- bucketbag-0.4.0/tests/test_iter_keys.py +43 -0
- bucketbag-0.4.0/tests/test_loaded_item.py +63 -0
- bucketbag-0.4.0/tests/test_pack.py +145 -0
- bucketbag-0.4.0/tests/test_package_meta.py +11 -0
- bucketbag-0.4.0/tests/test_prefix_semantics.py +123 -0
- bucketbag-0.4.0/tests/test_put_files.py +48 -0
|
@@ -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.
|
bucketbag-0.4.0/LICENSE
ADDED
|
@@ -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.
|
bucketbag-0.4.0/PKG-INFO
ADDED
|
@@ -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
|