theblob 0.1.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.
theblob-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.3
2
+ Name: theblob
3
+ Version: 0.1.0
4
+ Summary: An S3-compatible object store
5
+ Author: Stefane Fermigier
6
+ Author-email: Stefane Fermigier <sf@abilian.com>
7
+ Requires-Dist: cyclopts>=5.3.3
8
+ Requires-Dist: defusedxml>=0.7.1
9
+ Requires-Dist: httptools>=0.6.4
10
+ Requires-Dist: uvicorn>=0.54.0
11
+ Requires-Dist: uvloop>=0.21.0
12
+ Requires-Python: >=3.12
13
+ Description-Content-Type: text/markdown
14
+
15
+ # theblob
16
+
17
+ An S3-compatible object store written in Python. It starts as one command on one machine, and is designed to grow into a redundant cluster.
18
+
19
+ This is an experiment. Phase 1 (one server, one disk) is under construction.
20
+
21
+ ## Quick start
22
+
23
+ ```sh
24
+ uv sync
25
+ uv run theblob serve --data-dir ./data
26
+ ```
27
+
28
+ The first start creates an access key and prints it once. Use it with any S3 client:
29
+
30
+ ```python
31
+ import boto3
32
+
33
+ s3 = boto3.client(
34
+ "s3",
35
+ endpoint_url="http://127.0.0.1:9000",
36
+ aws_access_key_id="...",
37
+ aws_secret_access_key="...",
38
+ region_name="us-east-1",
39
+ )
40
+ s3.create_bucket(Bucket="my-bucket")
41
+ s3.put_object(Bucket="my-bucket", Key="hello.txt", Body=b"hello")
42
+ ```
43
+
44
+ Other commands: `theblob key create|list|delete` and `theblob fsck`. Run `theblob serve --help` for the server options, including TLS and virtual-hosted addressing.
45
+
46
+ ## Development
47
+
48
+ ```sh
49
+ make test # the whole test pyramid
50
+ make lint # ruff, ty, pyrefly, mypy
51
+ ```
52
+
53
+ Tests are in `tests/`: `a_unit` for pure logic, `b_integration` for real SQLite and files, `c_e2e` for real S3 clients (boto3, AWS CLI, rclone, s5cmd) against a real server over HTTP and HTTPS. The rclone and s5cmd tests are skipped if those tools are not installed.
@@ -0,0 +1,39 @@
1
+ # theblob
2
+
3
+ An S3-compatible object store written in Python. It starts as one command on one machine, and is designed to grow into a redundant cluster.
4
+
5
+ This is an experiment. Phase 1 (one server, one disk) is under construction.
6
+
7
+ ## Quick start
8
+
9
+ ```sh
10
+ uv sync
11
+ uv run theblob serve --data-dir ./data
12
+ ```
13
+
14
+ The first start creates an access key and prints it once. Use it with any S3 client:
15
+
16
+ ```python
17
+ import boto3
18
+
19
+ s3 = boto3.client(
20
+ "s3",
21
+ endpoint_url="http://127.0.0.1:9000",
22
+ aws_access_key_id="...",
23
+ aws_secret_access_key="...",
24
+ region_name="us-east-1",
25
+ )
26
+ s3.create_bucket(Bucket="my-bucket")
27
+ s3.put_object(Bucket="my-bucket", Key="hello.txt", Body=b"hello")
28
+ ```
29
+
30
+ Other commands: `theblob key create|list|delete` and `theblob fsck`. Run `theblob serve --help` for the server options, including TLS and virtual-hosted addressing.
31
+
32
+ ## Development
33
+
34
+ ```sh
35
+ make test # the whole test pyramid
36
+ make lint # ruff, ty, pyrefly, mypy
37
+ ```
38
+
39
+ Tests are in `tests/`: `a_unit` for pure logic, `b_integration` for real SQLite and files, `c_e2e` for real S3 clients (boto3, AWS CLI, rclone, s5cmd) against a real server over HTTP and HTTPS. The rclone and s5cmd tests are skipped if those tools are not installed.
@@ -0,0 +1,58 @@
1
+ [project]
2
+ name = "theblob"
3
+ version = "0.1.0"
4
+ description = "An S3-compatible object store"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "cyclopts>=5.3.3",
9
+ "defusedxml>=0.7.1",
10
+ "httptools>=0.6.4",
11
+ "uvicorn>=0.54.0",
12
+ "uvloop>=0.21.0",
13
+ ]
14
+
15
+ [[project.authors]]
16
+ name = "Stefane Fermigier"
17
+ email = "sf@abilian.com"
18
+
19
+ [project.scripts]
20
+ theblob = "theblob.cli:app"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.13.4,<0.14.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "anyio>=4.16.0",
29
+ "awscli>=1.46.2",
30
+ "boto3>=1.43.124",
31
+ "httpx>=0.28.1",
32
+ "mypy>=2.4.1",
33
+ "nox>=2026.10.20",
34
+ "pre-commit>=4.7.0",
35
+ "pyrefly>=1.4.1",
36
+ "pytest>=9.2.0",
37
+ "pytest-cov>=7.1.0",
38
+ "pytest-timeout>=2.4.0",
39
+ "ruff>=0.17.3",
40
+ "ty>=0.0.97",
41
+ "types-defusedxml>=0.7.0.20260504",
42
+ ]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+ timeout = 120
47
+ markers = [
48
+ "unit: fast tests of pure logic, no I/O",
49
+ "integration: components against real SQLite and real files",
50
+ "e2e: real S3 clients against a real server process",
51
+ "slow: takes more than a few seconds",
52
+ ]
53
+
54
+ [tool.pyrefly]
55
+ project-includes = [
56
+ "**/*.py*",
57
+ "**/*.ipynb",
58
+ ]
@@ -0,0 +1,57 @@
1
+ [project]
2
+ name = "theblob"
3
+ version = "0.1.0"
4
+ description = "An S3-compatible object store"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Stefane Fermigier", email = "sf@abilian.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "cyclopts>=5.3.3",
12
+ "defusedxml>=0.7.1",
13
+ "httptools>=0.6.4",
14
+ "uvicorn>=0.54.0",
15
+ "uvloop>=0.21.0",
16
+ ]
17
+
18
+ [project.scripts]
19
+ theblob = "theblob.cli:app"
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.13.4,<0.14.0"]
23
+ build-backend = "uv_build"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "anyio>=4.16.0",
28
+ "awscli>=1.46.2",
29
+ "boto3>=1.43.124",
30
+ "httpx>=0.28.1",
31
+ "mypy>=2.4.1",
32
+ "nox>=2026.10.20",
33
+ "pre-commit>=4.7.0",
34
+ "pyrefly>=1.4.1",
35
+ "pytest>=9.2.0",
36
+ "pytest-cov>=7.1.0",
37
+ "pytest-timeout>=2.4.0",
38
+ "ruff>=0.17.3",
39
+ "ty>=0.0.97",
40
+ "types-defusedxml>=0.7.0.20260504",
41
+ ]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+ timeout = 120
46
+ markers = [
47
+ "unit: fast tests of pure logic, no I/O",
48
+ "integration: components against real SQLite and real files",
49
+ "e2e: real S3 clients against a real server process",
50
+ "slow: takes more than a few seconds",
51
+ ]
52
+
53
+ [tool.pyrefly]
54
+ project-includes = [
55
+ "**/*.py*",
56
+ "**/*.ipynb",
57
+ ]
@@ -0,0 +1 @@
1
+ """theblob: an S3-compatible object store."""
@@ -0,0 +1,238 @@
1
+ """The ASGI application: the one place where S3 errors become HTTP responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import secrets
10
+ import time
11
+ from contextlib import aclosing, suppress
12
+ from dataclasses import replace
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ from theblob import handlers, maintenance, meta
17
+ from theblob.blobstore import BlobStore
18
+ from theblob.handlers import ClientDisconnectedError, Context, Request, Response
19
+ from theblob.meta import Database
20
+ from theblob.model import HttpRequest
21
+ from theblob.s3 import errors, routing, sigv4, xml
22
+ from theblob.s3.errors import S3Error
23
+ from theblob.s3.routing import BucketRoute, ObjectRoute, Operation, ServiceRoute
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
27
+ from typing import Any
28
+
29
+ type Scope = Mapping[str, Any]
30
+ type Message = Mapping[str, Any]
31
+ type Receive = Callable[[], Awaitable[Message]]
32
+ type Send = Callable[[Message], Awaitable[None]]
33
+ type App = Callable[[Scope, Receive, Send], Awaitable[None]]
34
+
35
+ ENV_DATA_DIR = "THEBLOB_DATA_DIR"
36
+ ENV_BASE_DOMAIN = "THEBLOB_BASE_DOMAIN"
37
+ META_FILE = "meta.db"
38
+
39
+ _CONNECTION_CLOSE = ("connection", "close")
40
+ _FIRST_ERROR_STATUS = 400
41
+ _FIRST_SERVER_ERROR_STATUS = 500
42
+ _STATUS_CLIENT_GONE = 499
43
+ _STATUSES_WITHOUT_BODY = frozenset({204, 304})
44
+
45
+ logger = logging.getLogger("theblob.access")
46
+
47
+
48
+ def create_app_from_env() -> App:
49
+ """Build the application in a worker process, from what `serve` set up."""
50
+ # Here and not in `serve`: each worker is its own process, with its own logging.
51
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
52
+ data_dir = Path(os.environ[ENV_DATA_DIR])
53
+ context = Context(Database(data_dir / META_FILE), BlobStore(data_dir))
54
+ return create_app(context, os.environ.get(ENV_BASE_DOMAIN))
55
+
56
+
57
+ def create_app(context: Context, base_domain: str | None) -> App:
58
+ async def app(scope: Scope, receive: Receive, send: Send) -> None:
59
+ if scope["type"] == "lifespan":
60
+ await _run_lifespan(context, receive, send)
61
+ else:
62
+ await _handle_http(context, base_domain, scope, receive, send)
63
+
64
+ return app
65
+
66
+
67
+ async def _run_lifespan(context: Context, receive: Receive, send: Send) -> None:
68
+ await receive()
69
+ task = asyncio.create_task(maintenance.run_forever(context.db, context.blobs))
70
+ await send({"type": "lifespan.startup.complete"})
71
+ await receive()
72
+ task.cancel()
73
+ with suppress(asyncio.CancelledError):
74
+ await task
75
+ context.db.close()
76
+ await send({"type": "lifespan.shutdown.complete"})
77
+
78
+
79
+ async def _handle_http(
80
+ context: Context,
81
+ base_domain: str | None,
82
+ scope: Scope,
83
+ receive: Receive,
84
+ send: Send,
85
+ ) -> None:
86
+ request_id = secrets.token_hex(8)
87
+ started = time.monotonic()
88
+ http_request = _parse_http(scope)
89
+ access = {
90
+ "request_id": request_id,
91
+ "method": http_request.method,
92
+ "path": http_request.raw_path.decode("latin-1"),
93
+ "operation": "unknown",
94
+ }
95
+ try:
96
+ route = routing.route(http_request, base_domain)
97
+ access["operation"] = route.operation.name
98
+ response = await _dispatch(context, http_request, route, receive)
99
+ except S3Error as error:
100
+ response = _error_response(error, http_request.raw_path, request_id)
101
+ except ClientDisconnectedError:
102
+ _log_access(access, _STATUS_CLIENT_GONE, started)
103
+ return
104
+ except Exception:
105
+ logger.exception("Unhandled error in request %s", request_id)
106
+ response = _error_response(
107
+ S3Error(errors.INTERNAL_ERROR), http_request.raw_path, request_id
108
+ )
109
+
110
+ if "expect" in http_request.headers and response.status >= _FIRST_ERROR_STATUS:
111
+ # The client held its body back and will send it after no 100 Continue. If
112
+ # the connection stayed open, those bytes would parse as the next request.
113
+ response = replace(response, headers=(*response.headers, _CONNECTION_CLOSE))
114
+ try:
115
+ await _send_response(response, http_request.method, request_id, receive, send)
116
+ finally:
117
+ # Including a stream that fails midway: its status was already sent.
118
+ _log_access(access, response.status, started)
119
+
120
+
121
+ def _parse_http(scope: Scope) -> HttpRequest:
122
+ headers: dict[str, str] = {}
123
+ for raw_name, raw_value in scope["headers"]:
124
+ name = raw_name.decode("latin-1").lower()
125
+ value = raw_value.decode("latin-1")
126
+ headers[name] = f"{headers[name]},{value}" if name in headers else value
127
+ raw_query: bytes = scope["query_string"]
128
+ return HttpRequest(
129
+ scope["method"],
130
+ scope["raw_path"],
131
+ raw_query,
132
+ routing.parse_query(raw_query),
133
+ headers,
134
+ )
135
+
136
+
137
+ async def _dispatch(
138
+ context: Context,
139
+ http_request: HttpRequest,
140
+ route: routing.Route,
141
+ receive: Receive,
142
+ ) -> Response:
143
+ if route.operation is Operation.HEALTH:
144
+ await meta.ping(context.db)
145
+ return Response(body=b"ok\n", headers=(("content-type", "text/plain"),))
146
+
147
+ claim = sigv4.parse_claim(http_request)
148
+ secret = await meta.get_secret(context.db, claim.credential.access_key)
149
+ seed = sigv4.verify(http_request, claim, secret, time.time())
150
+ request = Request(http_request.headers, http_request.query, receive, seed)
151
+
152
+ match route:
153
+ case ServiceRoute(operation=operation):
154
+ return await handlers.SERVICE_HANDLERS[operation](context, request)
155
+ case BucketRoute(operation=operation, bucket=bucket):
156
+ return await handlers.BUCKET_HANDLERS[operation](context, request, bucket)
157
+ case ObjectRoute(operation=operation, bucket=bucket, key=key):
158
+ return await handlers.OBJECT_HANDLERS[operation](
159
+ context, request, bucket, key
160
+ )
161
+
162
+
163
+ def _error_response(error: S3Error, raw_path: bytes, request_id: str) -> Response:
164
+ if error.error.status >= _FIRST_SERVER_ERROR_STATUS:
165
+ logger.error("%s: %s", request_id, error)
166
+ body = xml.render_error(
167
+ error.error.code,
168
+ error.message,
169
+ raw_path.decode("latin-1"),
170
+ request_id,
171
+ )
172
+ return Response(
173
+ error.error.status,
174
+ (("content-type", "application/xml"), *error.headers),
175
+ body,
176
+ )
177
+
178
+
179
+ async def _send_response(
180
+ response: Response, method: str, request_id: str, receive: Receive, send: Send
181
+ ) -> None:
182
+ headers = [
183
+ (name.encode("latin-1"), value.encode("latin-1"))
184
+ for name, value in response.headers
185
+ ]
186
+ headers.append((b"x-amz-request-id", request_id.encode()))
187
+ body = response.body
188
+ has_length = any(name == b"content-length" for name, _ in headers)
189
+ if (
190
+ isinstance(body, bytes)
191
+ and not has_length
192
+ and response.status not in _STATUSES_WITHOUT_BODY
193
+ ):
194
+ headers.append((b"content-length", str(len(body)).encode()))
195
+ await send({
196
+ "type": "http.response.start",
197
+ "status": response.status,
198
+ "headers": headers,
199
+ })
200
+ if isinstance(body, bytes):
201
+ payload = b"" if method == "HEAD" else body
202
+ await send({"type": "http.response.body", "body": payload})
203
+ else:
204
+ await _stream_body(body, receive, send)
205
+
206
+
207
+ async def _stream_body(
208
+ body: AsyncGenerator[bytes], receive: Receive, send: Send
209
+ ) -> None:
210
+ """Send a blob stream, and stop reading the disk once the client has gone.
211
+
212
+ uvicorn silently drops `send` after a disconnect, so nothing else would stop
213
+ an abandoned download from reading the whole object.
214
+ """
215
+ disconnected = asyncio.create_task(_wait_for_disconnect(receive))
216
+ try:
217
+ async with aclosing(body) as chunks:
218
+ async for chunk in chunks:
219
+ if disconnected.done():
220
+ return
221
+ await send({
222
+ "type": "http.response.body",
223
+ "body": chunk,
224
+ "more_body": True,
225
+ })
226
+ await send({"type": "http.response.body", "body": b""})
227
+ finally:
228
+ disconnected.cancel()
229
+
230
+
231
+ async def _wait_for_disconnect(receive: Receive) -> None:
232
+ while (await receive())["type"] != "http.disconnect":
233
+ pass
234
+
235
+
236
+ def _log_access(access: Mapping[str, str], status: int, started: float) -> None:
237
+ seconds = round(time.monotonic() - started, 6)
238
+ logger.info("%s", json.dumps({**access, "status": status, "seconds": seconds}))
@@ -0,0 +1,190 @@
1
+ """Blob files: durable writes, range reads and deletion.
2
+
3
+ A blob's ID is random, so user-supplied keys never reach the filesystem.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import os
10
+ import secrets
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Self
14
+
15
+ from theblob.model import Blob
16
+ from theblob.s3 import errors
17
+ from theblob.s3.errors import S3Error
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import AsyncGenerator, AsyncIterable, Iterable, Mapping
21
+ from types import TracebackType
22
+
23
+ from theblob.model import BlobSlice
24
+ from theblob.s3.payload import Hasher
25
+
26
+ BUFFER_BYTES = 1024 * 1024
27
+ TMP_MAX_AGE_SECONDS = 24 * 3600
28
+
29
+ _SHARD_LENGTH = 3
30
+ _SHARD_COUNT = 16**_SHARD_LENGTH
31
+
32
+
33
+ def existing_files(directory: Path) -> Iterable[tuple[str, os.stat_result]]:
34
+ """Yield the files in a directory that other workers are changing concurrently.
35
+
36
+ A file listed a moment ago may have been committed or collected since.
37
+ """
38
+ with os.scandir(directory) as entries:
39
+ for entry in entries:
40
+ try:
41
+ yield entry.path, entry.stat()
42
+ except FileNotFoundError:
43
+ continue
44
+
45
+
46
+ def _fsync_directory(path: Path) -> None:
47
+ descriptor = os.open(path, os.O_RDONLY)
48
+ try:
49
+ os.fsync(descriptor)
50
+ finally:
51
+ os.close(descriptor)
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class BlobStore:
56
+ """A directory of immutable blob files."""
57
+
58
+ root: Path
59
+
60
+ @property
61
+ def tmp_dir(self) -> Path:
62
+ return self.root / "tmp"
63
+
64
+ @property
65
+ def blobs_dir(self) -> Path:
66
+ return self.root / "blobs"
67
+
68
+ def initialize(self) -> None:
69
+ """Create every shard up front, so writes never create a directory."""
70
+ self.tmp_dir.mkdir(parents=True, exist_ok=True)
71
+ for number in range(_SHARD_COUNT):
72
+ shard = f"{number:0{_SHARD_LENGTH}x}"
73
+ (self.blobs_dir / shard).mkdir(parents=True, exist_ok=True)
74
+
75
+ def path(self, blob_id: str) -> Path:
76
+ return self.blobs_dir / blob_id[:_SHARD_LENGTH] / blob_id
77
+
78
+ def writer(self, hashers: Mapping[str, Hasher]) -> BlobWriter:
79
+ return BlobWriter(self, hashers)
80
+
81
+ def delete(self, blob_id: str) -> None:
82
+ self.path(blob_id).unlink(missing_ok=True)
83
+
84
+ def sweep_tmp(self, now: float) -> int:
85
+ """Remove temp files abandoned by a crash. Returns how many were removed."""
86
+ removed = 0
87
+ for path, stat in existing_files(self.tmp_dir):
88
+ if now - stat.st_mtime > TMP_MAX_AGE_SECONDS:
89
+ Path(path).unlink(missing_ok=True)
90
+ removed += 1
91
+ return removed
92
+
93
+ def scan(self) -> Iterable[tuple[str, int, float]]:
94
+ """Yield (blob ID, size, modification time) for every blob on disk."""
95
+ for shard in self.blobs_dir.iterdir():
96
+ for path, stat in existing_files(shard):
97
+ yield Path(path).name, stat.st_size, stat.st_mtime
98
+
99
+
100
+ class BlobWriter:
101
+ """One blob being written. It only becomes durable and named on `commit`."""
102
+
103
+ def __init__(self, store: BlobStore, hashers: Mapping[str, Hasher]) -> None:
104
+ self._store = store
105
+ self._hashers = hashers
106
+ self.blob_id = secrets.token_hex(16)
107
+ self._tmp_path = store.tmp_dir / self.blob_id
108
+ self._file = self._tmp_path.open("xb")
109
+ self._committed = False
110
+ self.size = 0
111
+
112
+ def __enter__(self) -> Self:
113
+ return self
114
+
115
+ def __exit__(
116
+ self,
117
+ exc_type: type[BaseException] | None,
118
+ exc: BaseException | None,
119
+ traceback: TracebackType | None,
120
+ ) -> None:
121
+ self._file.close()
122
+ if not self._committed:
123
+ self._tmp_path.unlink(missing_ok=True)
124
+
125
+ def write(self, data: bytes) -> None:
126
+ """Hash and write one buffer. Runs on a worker thread."""
127
+ for hasher in self._hashers.values():
128
+ hasher.update(data)
129
+ self._file.write(data)
130
+ self.size += len(data)
131
+
132
+ def digests(self) -> dict[str, bytes]:
133
+ return {name: hasher.digest() for name, hasher in self._hashers.items()}
134
+
135
+ def commit(self) -> Blob:
136
+ """Make the blob durable under its final name. Runs on a worker thread."""
137
+ self._file.flush()
138
+ # ponytail: on macOS fsync doesn't flush the drive cache. Linux is the
139
+ # target; add fcntl F_FULLFSYNC if it is ever deployed on macOS.
140
+ os.fsync(self._file.fileno())
141
+ self._file.close()
142
+
143
+ final_path = self._store.path(self.blob_id)
144
+ self._tmp_path.rename(final_path)
145
+ _fsync_directory(final_path.parent)
146
+ self._committed = True
147
+ return Blob(self.blob_id, self.size, self._hashers["md5"].digest().hex())
148
+
149
+
150
+ async def write_stream(
151
+ writer: BlobWriter, chunks: AsyncIterable[bytes], max_bytes: int
152
+ ) -> None:
153
+ """Feed a stream to a writer, one thread-pool trip per buffer of data."""
154
+ buffer = bytearray()
155
+ received = 0
156
+ async for chunk in chunks:
157
+ received += len(chunk)
158
+ if received > max_bytes:
159
+ raise S3Error(errors.ENTITY_TOO_LARGE)
160
+ buffer += chunk
161
+ if len(buffer) >= BUFFER_BYTES:
162
+ await asyncio.to_thread(writer.write, bytes(buffer))
163
+ buffer.clear()
164
+ if buffer:
165
+ await asyncio.to_thread(writer.write, bytes(buffer))
166
+
167
+
168
+ async def read_slices(
169
+ store: BlobStore, slices: Iterable[BlobSlice]
170
+ ) -> AsyncGenerator[bytes]:
171
+ """Stream byte ranges of blobs, opening each blob only when it is reached."""
172
+ for blob_slice in slices:
173
+ descriptor = await asyncio.to_thread(
174
+ os.open, store.path(blob_slice.blob_id), os.O_RDONLY
175
+ )
176
+ try:
177
+ position = blob_slice.offset
178
+ remaining = blob_slice.length
179
+ while remaining > 0:
180
+ data = await asyncio.to_thread(
181
+ os.pread, descriptor, min(BUFFER_BYTES, remaining), position
182
+ )
183
+ if not data:
184
+ message = f"Blob {blob_slice.blob_id} is shorter than its metadata."
185
+ raise OSError(message)
186
+ position += len(data)
187
+ remaining -= len(data)
188
+ yield data
189
+ finally:
190
+ os.close(descriptor)