bucketbag 0.4.0__py3-none-any.whl
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/__init__.py +70 -0
- bucketbag/_core.py +209 -0
- bucketbag/_hub.py +517 -0
- bucketbag/_log.py +50 -0
- bucketbag/bag.py +388 -0
- bucketbag/py.typed +0 -0
- bucketbag-0.4.0.dist-info/METADATA +180 -0
- bucketbag-0.4.0.dist-info/RECORD +10 -0
- bucketbag-0.4.0.dist-info/WHEEL +4 -0
- bucketbag-0.4.0.dist-info/licenses/LICENSE +21 -0
bucketbag/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""bucketbag — a minimal, toolz-style helper for Hugging Face bucket files.
|
|
2
|
+
|
|
3
|
+
Working with bucket-resident data means the same chore in every script: list a prefix,
|
|
4
|
+
download a batch of files to local disk, process them, delete them, repeat — without blowing
|
|
5
|
+
up disk or leaking temp files. ``huggingface_hub`` has the raw pieces; ``bucketbag`` wraps the
|
|
6
|
+
batch-download-process-cleanup loop into one composable verb.
|
|
7
|
+
|
|
8
|
+
The headline helper is :func:`batched_files` — think ``toolz.partition_all`` for *bucket
|
|
9
|
+
files*, except each batch is downloaded to a temp dir and **deleted before the next** (bounded
|
|
10
|
+
scratch space), with optional prefetch overlap. It is **file-type agnostic**: a
|
|
11
|
+
:class:`LoadedItem` is just a key + a local path + raw bytes, with opt-in lazy ``.image`` /
|
|
12
|
+
``.text()`` / ``.json()``.
|
|
13
|
+
|
|
14
|
+
from bucketbag import batched_files
|
|
15
|
+
|
|
16
|
+
for batch in batched_files("davanstrien/my-bucket", include="images/**/*.jpg", n=20):
|
|
17
|
+
for it in batch:
|
|
18
|
+
do_something(it.path) # or it.bytes / it.image / it.text() / it.json()
|
|
19
|
+
# <- this batch's files are auto-deleted as the loop advances
|
|
20
|
+
|
|
21
|
+
This is **not a framework**: no executors and no Jobs orchestration. The helpers compose with
|
|
22
|
+
plain ``for`` loops; :class:`Bag` runs the same loop as a lazy plan.
|
|
23
|
+
|
|
24
|
+
Layout: ``_core`` holds the pure rules (addressing, filters, batching, resume), ``_hub`` does
|
|
25
|
+
every hub call and owns the temp files, ``bag`` is the lazy plan. CONTRACT.md states the rules.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
|
|
32
|
+
# Turn on xet's high-performance mode before huggingface_hub is imported. This is the
|
|
33
|
+
# documented, safe transport boost. Raising xet's per-process file-download concurrency is the
|
|
34
|
+
# big lever for many small files, but it over-subscribes large ones, so it stays opt-in via
|
|
35
|
+
# boost(). Opt out of even this default with BUCKETBAG_NO_XET_TUNE=1.
|
|
36
|
+
if os.environ.get("BUCKETBAG_NO_XET_TUNE", "").lower() not in ("1", "true", "yes", "on"):
|
|
37
|
+
os.environ.setdefault("HF_XET_HIGH_PERFORMANCE", "1")
|
|
38
|
+
|
|
39
|
+
from toolz import partition_all # noqa: E402
|
|
40
|
+
|
|
41
|
+
from ._hub import ( # noqa: E402
|
|
42
|
+
LoadedItem,
|
|
43
|
+
batched_files,
|
|
44
|
+
boost,
|
|
45
|
+
completed_keys,
|
|
46
|
+
iter_keys,
|
|
47
|
+
put_bytes,
|
|
48
|
+
put_files,
|
|
49
|
+
put_text,
|
|
50
|
+
)
|
|
51
|
+
from ._log import install as _install_log # noqa: E402
|
|
52
|
+
from .bag import Bag, BagStats # noqa: E402
|
|
53
|
+
|
|
54
|
+
_install_log() # [bucketbag] lines on stderr until the caller configures logging
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"Bag",
|
|
58
|
+
"BagStats",
|
|
59
|
+
"LoadedItem",
|
|
60
|
+
"batched_files",
|
|
61
|
+
"boost",
|
|
62
|
+
"completed_keys",
|
|
63
|
+
"iter_keys",
|
|
64
|
+
"partition_all",
|
|
65
|
+
"put_bytes",
|
|
66
|
+
"put_files",
|
|
67
|
+
"put_text",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
__version__ = "0.4.0"
|
bucketbag/_core.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""The pure core: addressing, listing filters, batching and resume decisions.
|
|
2
|
+
|
|
3
|
+
Nothing here calls the Hub or touches disk. ``_hub.py`` does the I/O and asks these functions
|
|
4
|
+
what to do, so each rule in CONTRACT.md can be tested on its own.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import math
|
|
11
|
+
import numbers
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
from fsspec.utils import glob_translate
|
|
18
|
+
from huggingface_hub import BucketFile, parse_hf_uri
|
|
19
|
+
|
|
20
|
+
_HF_SCHEME = "hf://"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Addressing (CONTRACT §1)
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
def _join(prefix: str, rest: str) -> str:
|
|
27
|
+
"""Join two key prefixes as directories: ``"a/b" + "k"`` and ``"a/b/" + "k"`` give ``a/b/k``."""
|
|
28
|
+
if not prefix:
|
|
29
|
+
return rest
|
|
30
|
+
if not rest:
|
|
31
|
+
return prefix
|
|
32
|
+
return prefix.rstrip("/") + "/" + rest
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class BucketRef:
|
|
37
|
+
"""A parsed bucket reference: the bucket id and a key prefix.
|
|
38
|
+
|
|
39
|
+
The prefix is a string prefix of keys, as on the Hub: a trailing ``/`` limits it to one
|
|
40
|
+
directory (``a/b/`` lists ``a/b/x``, not ``a/bc/y``).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
bucket_id: str
|
|
44
|
+
prefix: str = ""
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def parse(cls, ref: str) -> BucketRef:
|
|
48
|
+
"""Read ``ns/bucket[/prefix]``, ``hf://buckets/ns/bucket[/prefix]`` or a Hub web URL."""
|
|
49
|
+
if ref.startswith(_HF_SCHEME) or "://" in ref:
|
|
50
|
+
return cls._parse_uri(ref)
|
|
51
|
+
parts = ref.lstrip("/").split("/", 2)
|
|
52
|
+
if len(parts) < 2 or not parts[0] or not parts[1]:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"Bucket reference must be 'ns/bucket' (optionally with a prefix), got: {ref!r}"
|
|
55
|
+
)
|
|
56
|
+
prefix = parts[2] if len(parts) == 3 else ""
|
|
57
|
+
return cls(f"{parts[0]}/{parts[1]}", prefix)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def _parse_uri(cls, ref: str) -> BucketRef:
|
|
61
|
+
parsed = parse_hf_uri(ref)
|
|
62
|
+
if parsed.type != "bucket":
|
|
63
|
+
raise ValueError(f"Not a bucket reference: {ref!r} points at a {parsed.type}.")
|
|
64
|
+
# parse_hf_uri drops a trailing "/", so read it back: from the URI itself, or from a web
|
|
65
|
+
# URL's path, where a query string must neither add nor remove it.
|
|
66
|
+
path = ref if ref.startswith(_HF_SCHEME) else urlsplit(ref).path
|
|
67
|
+
slash = "/" if parsed.path_in_repo and path.endswith("/") else ""
|
|
68
|
+
return cls(parsed.id, parsed.path_in_repo + slash)
|
|
69
|
+
|
|
70
|
+
def under(self, prefix: str | None) -> BucketRef:
|
|
71
|
+
"""This reference with ``prefix`` joined below its own prefix, as directories."""
|
|
72
|
+
if not prefix:
|
|
73
|
+
return self
|
|
74
|
+
return BucketRef(self.bucket_id, _join(self.prefix, prefix))
|
|
75
|
+
|
|
76
|
+
def key_for(self, key: str) -> str:
|
|
77
|
+
"""The full bucket key where ``put_files`` writes ``key``."""
|
|
78
|
+
return _join(self.prefix, key)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Listing filters (CONTRACT §1)
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
@functools.lru_cache(maxsize=256)
|
|
85
|
+
def _glob_to_re(pattern: str) -> re.Pattern[str]:
|
|
86
|
+
"""Compile a glob with fsspec's rules, the ones ``HfFileSystem.glob`` uses.
|
|
87
|
+
|
|
88
|
+
``*`` stays inside one path segment, ``**`` as a whole segment crosses ``/``, ``?`` is one
|
|
89
|
+
character and ``[...]`` a character class.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
return re.compile(glob_translate(pattern))
|
|
93
|
+
except ValueError as exc:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"Invalid glob {pattern!r} ({exc}). Write '**' as its own path segment, "
|
|
96
|
+
"e.g. '**/*.jp2'."
|
|
97
|
+
) from exc
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _glob_prefix(pattern: str) -> str:
|
|
101
|
+
"""The literal leading directory of a glob, used as a cheap server-side list prefix.
|
|
102
|
+
|
|
103
|
+
``"images/**/*.jp2" -> "images/"``; ``"a/b/c.txt" -> "a/b/"``; ``"*.jp2" -> ""``.
|
|
104
|
+
"""
|
|
105
|
+
head = pattern
|
|
106
|
+
for i, ch in enumerate(pattern):
|
|
107
|
+
if ch in "*?[":
|
|
108
|
+
head = pattern[:i]
|
|
109
|
+
break
|
|
110
|
+
slash = head.rfind("/")
|
|
111
|
+
return head[: slash + 1] if slash >= 0 else ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _keeps(
|
|
115
|
+
key: str,
|
|
116
|
+
prefix: str,
|
|
117
|
+
include: re.Pattern[str] | None,
|
|
118
|
+
exclude: re.Pattern[str] | None,
|
|
119
|
+
start_after: str | None,
|
|
120
|
+
) -> bool:
|
|
121
|
+
"""Whether a listing keeps ``key``.
|
|
122
|
+
|
|
123
|
+
The prefix check also guards against a listing that returns keys outside the prefix.
|
|
124
|
+
"""
|
|
125
|
+
if not key.startswith(prefix):
|
|
126
|
+
return False
|
|
127
|
+
if include is not None and include.match(key) is None:
|
|
128
|
+
return False
|
|
129
|
+
if exclude is not None and exclude.match(key) is not None:
|
|
130
|
+
return False
|
|
131
|
+
return start_after is None or key > start_after
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Batches (CONTRACT §5)
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
def _is_count(value: object) -> bool:
|
|
138
|
+
"""A whole number that is not a bool (Python counts ``True`` as 1)."""
|
|
139
|
+
return isinstance(value, numbers.Integral) and not isinstance(value, bool)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _is_byte_cap(value: object) -> bool:
|
|
143
|
+
"""A finite real number that is not a bool. ``4e9`` is fine.
|
|
144
|
+
|
|
145
|
+
nan and inf compare False with every running total, so ``_pack`` would never split by
|
|
146
|
+
bytes and one batch would hold every file.
|
|
147
|
+
"""
|
|
148
|
+
if isinstance(value, bool) or not isinstance(value, numbers.Real):
|
|
149
|
+
return False
|
|
150
|
+
return math.isfinite(value)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _check_batch_settings(n: int | None, max_bytes: float | None, prefetch: int = 0) -> None:
|
|
154
|
+
"""Refuse batch settings that cannot work, before anything is listed or downloaded."""
|
|
155
|
+
if n is not None and not _is_count(n):
|
|
156
|
+
raise TypeError(f"n must be a whole number or None, got {n!r}.")
|
|
157
|
+
if max_bytes is not None and not _is_byte_cap(max_bytes):
|
|
158
|
+
raise TypeError(f"max_bytes must be a finite number or None, got {max_bytes!r}.")
|
|
159
|
+
if not _is_count(prefetch):
|
|
160
|
+
raise TypeError(f"prefetch must be a whole number, got {prefetch!r}.")
|
|
161
|
+
if n is None and max_bytes is None:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
"Batches need a bound: set the file count (n / batch_size), max_bytes, or both. "
|
|
164
|
+
"With neither, one batch would hold every file."
|
|
165
|
+
)
|
|
166
|
+
if n is not None and n < 1:
|
|
167
|
+
raise ValueError(f"The file count per batch must be at least 1, got {n}.")
|
|
168
|
+
if max_bytes is not None and max_bytes < 1:
|
|
169
|
+
raise ValueError(f"max_bytes must be at least 1, got {max_bytes}.")
|
|
170
|
+
if prefetch < 0:
|
|
171
|
+
raise ValueError(f"prefetch must be 0 or more, got {prefetch}.")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _pack(
|
|
175
|
+
remotes: Sequence[BucketFile | str], n: int | None, max_bytes: float | None
|
|
176
|
+
) -> Iterator[list[BucketFile | str]]:
|
|
177
|
+
"""Group files into batches capped by count (``n``) and/or total bytes (``max_bytes``).
|
|
178
|
+
|
|
179
|
+
Whichever cap is hit first ends a batch; a single file larger than ``max_bytes`` forms its own
|
|
180
|
+
batch (files can't be split). Sizes come from ``BucketFile.size``; unknown sizes count as 0.
|
|
181
|
+
"""
|
|
182
|
+
batch: list[BucketFile | str] = []
|
|
183
|
+
total = 0
|
|
184
|
+
for r in remotes:
|
|
185
|
+
size = getattr(r, "size", 0) or 0
|
|
186
|
+
full_by_count = n is not None and len(batch) >= n
|
|
187
|
+
full_by_bytes = max_bytes is not None and bool(batch) and total + size > max_bytes
|
|
188
|
+
if full_by_count or full_by_bytes:
|
|
189
|
+
yield batch
|
|
190
|
+
batch, total = [], 0
|
|
191
|
+
batch.append(r)
|
|
192
|
+
total += size
|
|
193
|
+
if batch:
|
|
194
|
+
yield batch
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# Resume (CONTRACT §3)
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
def _split_done(
|
|
201
|
+
files: Sequence[BucketFile], out_keys: Iterable[str], out: BucketRef, done: str
|
|
202
|
+
) -> tuple[list[BucketFile], int]:
|
|
203
|
+
"""The inputs still to do, and how many are done.
|
|
204
|
+
|
|
205
|
+
An input ``k`` is done when the output ``k + done`` exists under ``out``.
|
|
206
|
+
"""
|
|
207
|
+
existing = set(out_keys)
|
|
208
|
+
todo = [f for f in files if out.key_for(f.path + done) not in existing]
|
|
209
|
+
return todo, len(files) - len(todo)
|