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 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)