theblob 0.1.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.
theblob/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """theblob: an S3-compatible object store."""
theblob/asgi.py ADDED
@@ -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}))
theblob/blobstore.py ADDED
@@ -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)
theblob/cli.py ADDED
@@ -0,0 +1,211 @@
1
+ """The `theblob` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import secrets
8
+ import string
9
+ import sys
10
+ import time
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Annotated
14
+
15
+ import cyclopts
16
+ import uvicorn
17
+
18
+ from theblob import asgi, maintenance, meta
19
+ from theblob.blobstore import BlobStore
20
+ from theblob.meta import Database
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Awaitable, Callable
24
+ from typing import Concatenate
25
+
26
+ _DEFAULT_DATA_DIR = Path("./data")
27
+ _ACCESS_KEY_ALPHABET = string.ascii_uppercase + string.digits
28
+ _ACCESS_KEY_LENGTH = 20
29
+ _SECRET_BYTES = 30
30
+
31
+ DataDir = Annotated[Path, cyclopts.Parameter(help="Directory holding all stored data.")]
32
+
33
+ app = cyclopts.App(name="theblob", help="An S3-compatible object store.")
34
+ key_app = cyclopts.App(name="key", help="Manage access keys.")
35
+ app.command(key_app)
36
+
37
+
38
+ @cyclopts.Parameter(name="*")
39
+ @dataclass(frozen=True, kw_only=True)
40
+ class ServeOptions:
41
+ """Options of `theblob serve`.
42
+
43
+ Parameters
44
+ ----------
45
+ data_dir
46
+ Directory holding all stored data.
47
+ host
48
+ Address to listen on.
49
+ port
50
+ Port to listen on.
51
+ workers
52
+ Number of worker processes; defaults to the number of CPUs.
53
+ domain
54
+ Base domain enabling virtual-hosted addressing (bucket.domain).
55
+ ssl_certfile
56
+ TLS certificate, to serve HTTPS directly.
57
+ ssl_keyfile
58
+ TLS private key.
59
+ shutdown_grace
60
+ Seconds to let requests finish on shutdown. Without a limit, one client
61
+ that stops reading would prevent the server from ever exiting.
62
+ """
63
+
64
+ data_dir: Path = _DEFAULT_DATA_DIR
65
+ host: str = "127.0.0.1"
66
+ port: int = 9000
67
+ workers: int | None = None
68
+ domain: str | None = None
69
+ ssl_certfile: Path | None = None
70
+ ssl_keyfile: Path | None = None
71
+ shutdown_grace: int = 20
72
+
73
+
74
+ @app.command
75
+ def serve(options: ServeOptions | None = None) -> None:
76
+ """Run the S3 server."""
77
+ options = options or ServeOptions()
78
+ _prepare(options.data_dir)
79
+ asyncio.run(_create_first_key_if_needed(options.data_dir))
80
+
81
+ os.environ[asgi.ENV_DATA_DIR] = str(options.data_dir.resolve())
82
+ if options.domain:
83
+ os.environ[asgi.ENV_BASE_DOMAIN] = options.domain
84
+ else:
85
+ os.environ.pop(asgi.ENV_BASE_DOMAIN, None)
86
+
87
+ uvicorn.run(
88
+ "theblob.asgi:create_app_from_env",
89
+ factory=True,
90
+ host=options.host,
91
+ port=options.port,
92
+ workers=options.workers or os.cpu_count(),
93
+ http="httptools",
94
+ loop="uvloop",
95
+ lifespan="on",
96
+ ws="none",
97
+ access_log=False,
98
+ ssl_certfile=options.ssl_certfile,
99
+ ssl_keyfile=options.ssl_keyfile,
100
+ timeout_graceful_shutdown=options.shutdown_grace,
101
+ )
102
+
103
+
104
+ @key_app.command(name="create")
105
+ def key_create(*, data_dir: DataDir = _DEFAULT_DATA_DIR) -> None:
106
+ """Create an access key and print it. The secret isn't shown again."""
107
+ _prepare(data_dir)
108
+ access_key, secret = asyncio.run(_create_key(data_dir))
109
+ print(f"access key: {access_key}")
110
+ print(f"secret key: {secret}")
111
+
112
+
113
+ @key_app.command(name="list")
114
+ def key_list(*, data_dir: DataDir = _DEFAULT_DATA_DIR) -> None:
115
+ """List access keys."""
116
+ _open_existing(data_dir)
117
+ for access_key in asyncio.run(_with_db(data_dir, meta.list_access_keys)):
118
+ print(access_key)
119
+
120
+
121
+ @key_app.command(name="delete")
122
+ def key_delete(access_key: str, /, *, data_dir: DataDir = _DEFAULT_DATA_DIR) -> None:
123
+ """Delete an access key."""
124
+ _open_existing(data_dir)
125
+ if not asyncio.run(_with_db(data_dir, meta.delete_access_key, access_key)):
126
+ sys.exit(f"theblob: no such access key: {access_key}")
127
+
128
+
129
+ @app.command
130
+ def fsck(
131
+ *, data_dir: DataDir = _DEFAULT_DATA_DIR, delete_orphans: bool = False
132
+ ) -> None:
133
+ """Compare the metadata with the disk; exit non-zero if they disagree.
134
+
135
+ Parameters
136
+ ----------
137
+ delete_orphans
138
+ Remove blob files that have no metadata and are over a day old.
139
+ """
140
+ _open_existing(data_dir)
141
+ blobs = BlobStore(data_dir)
142
+ report = asyncio.run(_with_db(data_dir, _run_fsck, blobs))
143
+ for label, blob_ids in (
144
+ ("orphan", report.orphans),
145
+ ("recent orphan (may be in flight)", report.young_orphans),
146
+ ("missing", report.missing),
147
+ ("wrong size", report.wrong_size),
148
+ ):
149
+ for blob_id in blob_ids:
150
+ print(f"{label}: {blob_id}")
151
+ if delete_orphans:
152
+ for blob_id in report.orphans:
153
+ blobs.delete(blob_id)
154
+ print(f"deleted {len(report.orphans)} orphans")
155
+ if report.missing or report.wrong_size or (report.orphans and not delete_orphans):
156
+ sys.exit(1)
157
+ print("clean")
158
+
159
+
160
+ async def _run_fsck(db: Database, blobs: BlobStore) -> maintenance.FsckReport:
161
+ return await maintenance.fsck(db, blobs, time.time())
162
+
163
+
164
+ def _prepare(data_dir: Path) -> None:
165
+ """Create the directory layout and schema, before any worker can start."""
166
+ data_dir.mkdir(parents=True, exist_ok=True)
167
+ data_dir.chmod(0o700) # meta.db holds secrets, even if the directory existed.
168
+ BlobStore(data_dir).initialize()
169
+ meta.initialize(data_dir / asgi.META_FILE)
170
+
171
+
172
+ def _open_existing(data_dir: Path) -> None:
173
+ """Refuse a path that holds no store: reporting on it would be false reassurance."""
174
+ if not (data_dir / asgi.META_FILE).exists():
175
+ sys.exit(f"theblob: no store found in {data_dir}")
176
+ meta.initialize(data_dir / asgi.META_FILE)
177
+
178
+
179
+ async def _with_db[**P, R](
180
+ data_dir: Path,
181
+ function: Callable[Concatenate[Database, P], Awaitable[R]],
182
+ *args: P.args,
183
+ **kwargs: P.kwargs,
184
+ ) -> R:
185
+ db = Database(data_dir / asgi.META_FILE)
186
+ try:
187
+ return await function(db, *args, **kwargs)
188
+ finally:
189
+ db.close()
190
+
191
+
192
+ async def _create_key(data_dir: Path) -> tuple[str, str]:
193
+ access_key = "".join(
194
+ secrets.choice(_ACCESS_KEY_ALPHABET) for _ in range(_ACCESS_KEY_LENGTH)
195
+ )
196
+ secret = secrets.token_urlsafe(_SECRET_BYTES)
197
+ await _with_db(data_dir, meta.add_access_key, access_key, secret)
198
+ return access_key, secret
199
+
200
+
201
+ async def _create_first_key_if_needed(data_dir: Path) -> None:
202
+ """Make a fresh store usable at once: no key means nobody could ever connect."""
203
+ if await _with_db(data_dir, meta.list_access_keys):
204
+ return
205
+ access_key, secret = await _create_key(data_dir)
206
+ print(
207
+ "Created the first access key; the secret isn't shown again:\n"
208
+ f" access key: {access_key}\n"
209
+ f" secret key: {secret}",
210
+ flush=True,
211
+ )