tiny-object-storage 0.1.1__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.
@@ -0,0 +1,209 @@
1
+ import hashlib
2
+ import json
3
+ import re
4
+ from datetime import UTC, datetime
5
+ from email.utils import format_datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from xml.etree.ElementTree import Element, SubElement, tostring
9
+
10
+ from tiny_object_storage.core.errors import InvalidBucketNameError, InvalidObjectKeyError
11
+
12
+ _BUCKET_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$")
13
+
14
+
15
+ def utc_now() -> datetime:
16
+ # Return the current UTC datetime.
17
+ return datetime.now(UTC)
18
+
19
+
20
+ def utc_now_iso() -> str:
21
+ # Return the current UTC time in ISO 8601 format.
22
+ return utc_now().isoformat()
23
+
24
+
25
+ def parse_iso_datetime(value: str | None) -> datetime | None:
26
+ # Parse an ISO 8601 datetime string.
27
+ if not value:
28
+ return None
29
+
30
+ normalized = value.replace("Z", "+00:00")
31
+ parsed = datetime.fromisoformat(normalized)
32
+ if parsed.tzinfo is None:
33
+ parsed = parsed.replace(tzinfo=UTC)
34
+ return parsed.astimezone(UTC)
35
+
36
+
37
+ def http_date(value: str | None) -> str | None:
38
+ # Convert an ISO 8601 timestamp to an RFC 7231 HTTP date string.
39
+ parsed = parse_iso_datetime(value)
40
+ if parsed is None:
41
+ return None
42
+ return format_datetime(parsed, usegmt=True)
43
+
44
+
45
+ def validate_bucket_name(bucket_name: str) -> None:
46
+ # Validate bucket name using a simplified S3-like rule set.
47
+ if not bucket_name:
48
+ raise InvalidBucketNameError("Bucket name cannot be empty.")
49
+
50
+ if len(bucket_name) < 3 or len(bucket_name) > 63:
51
+ raise InvalidBucketNameError("Bucket name length must be between 3 and 63 characters.")
52
+
53
+ if not _BUCKET_NAME_PATTERN.fullmatch(bucket_name):
54
+ raise InvalidBucketNameError(
55
+ "Bucket name must contain only lowercase letters, numbers, dots, and hyphens."
56
+ )
57
+
58
+ if ".." in bucket_name or ".-" in bucket_name or "-." in bucket_name:
59
+ raise InvalidBucketNameError("Bucket name contains an invalid sequence.")
60
+
61
+ if bucket_name.startswith(".") or bucket_name.endswith("."):
62
+ raise InvalidBucketNameError("Bucket name cannot start or end with a dot.")
63
+
64
+ if bucket_name.startswith("-") or bucket_name.endswith("-"):
65
+ raise InvalidBucketNameError("Bucket name cannot start or end with a hyphen.")
66
+
67
+
68
+ def validate_object_key(object_key: str) -> None:
69
+ # Validate object key and reject obviously dangerous values.
70
+ if not object_key:
71
+ raise InvalidObjectKeyError("Object key cannot be empty.")
72
+
73
+ normalized = object_key.strip()
74
+ if not normalized:
75
+ raise InvalidObjectKeyError("Object key cannot be blank.")
76
+
77
+ if normalized.startswith("/"):
78
+ raise InvalidObjectKeyError("Object key cannot start with '/'.")
79
+
80
+ parts = normalized.split("/")
81
+ for part in parts:
82
+ if part in {"", ".", ".."}:
83
+ raise InvalidObjectKeyError("Object key contains an invalid path segment.")
84
+
85
+ if "\x00" in normalized:
86
+ raise InvalidObjectKeyError("Object key contains a null byte.")
87
+
88
+
89
+ def ensure_path_within_base(base_path: Path, target_path: Path) -> None:
90
+ # Ensure that the target path stays within the configured base directory.
91
+ base_resolved = base_path.resolve()
92
+ target_resolved = target_path.resolve()
93
+
94
+ if base_resolved not in {target_resolved, *target_resolved.parents}:
95
+ raise InvalidObjectKeyError("Resolved path escapes the storage base directory.")
96
+
97
+
98
+ def bucket_path(base_dir: Path, bucket_name: str) -> Path:
99
+ # Build a safe bucket path.
100
+ validate_bucket_name(bucket_name)
101
+ path = base_dir / bucket_name
102
+ ensure_path_within_base(base_dir, path)
103
+ return path
104
+
105
+
106
+ def object_path(base_dir: Path, bucket_name: str, object_key: str) -> Path:
107
+ # Build a safe object path.
108
+ validate_bucket_name(bucket_name)
109
+ validate_object_key(object_key)
110
+
111
+ path = base_dir / bucket_name / Path(object_key)
112
+ ensure_path_within_base(base_dir / bucket_name, path)
113
+ return path
114
+
115
+
116
+ def metadata_dir_path(base_dir: Path, bucket_name: str) -> Path:
117
+ # Build the metadata directory path for a bucket.
118
+ path = bucket_path(base_dir, bucket_name) / ".meta"
119
+ ensure_path_within_base(base_dir / bucket_name, path)
120
+ return path
121
+
122
+
123
+ def metadata_file_path(base_dir: Path, bucket_name: str, object_key: str) -> Path:
124
+ # Build a safe metadata file path for an object.
125
+ validate_object_key(object_key)
126
+
127
+ safe_name = object_key.replace("/", "__")
128
+ path = metadata_dir_path(base_dir, bucket_name) / f"{safe_name}.json"
129
+ ensure_path_within_base(base_dir / bucket_name, path)
130
+ return path
131
+
132
+
133
+ def compute_etag(data: bytes) -> str:
134
+ # Compute a simple ETag using MD5.
135
+ return hashlib.md5(data).hexdigest() # noqa: S324
136
+
137
+
138
+ def write_json(file_path: Path, payload: dict[str, Any]) -> None:
139
+ # Write JSON content to disk.
140
+ file_path.parent.mkdir(parents=True, exist_ok=True)
141
+ with file_path.open("w", encoding="utf-8") as file:
142
+ json.dump(payload, file, indent=2, ensure_ascii=False)
143
+
144
+
145
+ def read_json(file_path: Path) -> dict[str, Any]:
146
+ # Read JSON content from disk.
147
+ with file_path.open("r", encoding="utf-8") as file:
148
+ return json.load(file) # type: ignore
149
+
150
+
151
+ def _xml_bytes(root: Element) -> bytes:
152
+ # Serialize an XML tree with a standard XML declaration.
153
+ body = tostring(root, encoding="utf-8", xml_declaration=True)
154
+ return body # type: ignore
155
+
156
+
157
+ def build_list_buckets_xml(buckets: list[dict[str, Any]]) -> bytes:
158
+ # Build a minimal S3-style XML response for bucket listing.
159
+ root = Element("ListAllMyBucketsResult")
160
+ owner = SubElement(root, "Owner")
161
+ SubElement(owner, "ID").text = "tiny-object-storage"
162
+ SubElement(owner, "DisplayName").text = "tiny-object-storage"
163
+
164
+ buckets_el = SubElement(root, "Buckets")
165
+ for bucket in buckets:
166
+ bucket_el = SubElement(buckets_el, "Bucket")
167
+ SubElement(bucket_el, "Name").text = bucket["name"]
168
+ if bucket.get("created_at"):
169
+ created = parse_iso_datetime(bucket["created_at"])
170
+ if created is not None:
171
+ SubElement(bucket_el, "CreationDate").text = created.isoformat().replace(
172
+ "+00:00", "Z"
173
+ )
174
+
175
+ return _xml_bytes(root)
176
+
177
+
178
+ def build_list_objects_v2_xml(
179
+ bucket_name: str,
180
+ objects: list[dict[str, Any]],
181
+ prefix: str = "",
182
+ max_keys: int = 1000,
183
+ is_truncated: bool = False,
184
+ ) -> bytes:
185
+ # Build a minimal S3 ListObjectsV2 XML response.
186
+ root = Element("ListBucketResult")
187
+ root.set("xmlns", "http://s3.amazonaws.com/doc/2006-03-01/")
188
+
189
+ SubElement(root, "Name").text = bucket_name
190
+ SubElement(root, "Prefix").text = prefix
191
+ SubElement(root, "KeyCount").text = str(len(objects))
192
+ SubElement(root, "MaxKeys").text = str(max_keys)
193
+ SubElement(root, "IsTruncated").text = "true" if is_truncated else "false"
194
+
195
+ for item in objects:
196
+ contents = SubElement(root, "Contents")
197
+ SubElement(contents, "Key").text = item["object_key"]
198
+
199
+ updated = parse_iso_datetime(item.get("updated_at") or item.get("created_at"))
200
+ if updated is not None:
201
+ SubElement(contents, "LastModified").text = updated.isoformat().replace("+00:00", "Z")
202
+ else:
203
+ SubElement(contents, "LastModified").text = utc_now_iso().replace("+00:00", "Z")
204
+
205
+ SubElement(contents, "ETag").text = f"\"{item['etag']}\""
206
+ SubElement(contents, "Size").text = str(item["size"])
207
+ SubElement(contents, "StorageClass").text = "STANDARD"
208
+
209
+ return _xml_bytes(root)
@@ -0,0 +1,73 @@
1
+ import logging
2
+ from logging.handlers import RotatingFileHandler
3
+ from pathlib import Path
4
+
5
+ LOG_FORMAT = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
6
+ DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
7
+
8
+
9
+ def _build_formatter() -> logging.Formatter:
10
+ # Create a shared formatter for all handlers.
11
+ return logging.Formatter(fmt=LOG_FORMAT, datefmt=DATE_FORMAT)
12
+
13
+
14
+ def _build_console_handler(log_level: str) -> logging.Handler:
15
+ # Create console log handler.
16
+ handler = logging.StreamHandler()
17
+ handler.setLevel(log_level.upper())
18
+ handler.setFormatter(_build_formatter())
19
+ return handler
20
+
21
+
22
+ def _build_rotating_file_handler(file_path: Path, log_level: str) -> logging.Handler:
23
+ # Create a rotating file handler.
24
+ handler = RotatingFileHandler(
25
+ filename=file_path,
26
+ maxBytes=5 * 1024 * 1024,
27
+ backupCount=5,
28
+ encoding="utf-8",
29
+ )
30
+ handler.setLevel(log_level.upper())
31
+ handler.setFormatter(_build_formatter())
32
+ return handler
33
+
34
+
35
+ def _build_error_file_handler(file_path: Path) -> logging.Handler:
36
+ # Create a dedicated error log file handler.
37
+ handler = RotatingFileHandler(
38
+ filename=file_path,
39
+ maxBytes=5 * 1024 * 1024,
40
+ backupCount=5,
41
+ encoding="utf-8",
42
+ )
43
+ handler.setLevel(logging.ERROR)
44
+ handler.setFormatter(_build_formatter())
45
+ return handler
46
+
47
+
48
+ def configure_logging(log_dir: Path, log_level: str) -> None:
49
+ # Configure root logger once for the entire application.
50
+ log_dir.mkdir(parents=True, exist_ok=True)
51
+
52
+ root_logger = logging.getLogger()
53
+ if root_logger.handlers:
54
+ return
55
+
56
+ root_logger.setLevel(logging.DEBUG)
57
+
58
+ server_log_path = log_dir / "server.log"
59
+ error_log_path = log_dir / "error.log"
60
+
61
+ handlers = [
62
+ _build_console_handler(log_level=log_level),
63
+ _build_rotating_file_handler(file_path=server_log_path, log_level=log_level),
64
+ _build_error_file_handler(file_path=error_log_path),
65
+ ]
66
+
67
+ for handler in handlers:
68
+ root_logger.addHandler(handler)
69
+
70
+
71
+ def get_logger(name: str) -> logging.Logger:
72
+ # Return a named logger instance.
73
+ return logging.getLogger(name)
@@ -0,0 +1,199 @@
1
+ import time
2
+ from collections.abc import AsyncIterator, Awaitable, Callable
3
+ from contextlib import asynccontextmanager
4
+
5
+ from fastapi import FastAPI, Request
6
+ from fastapi.responses import Response
7
+
8
+ from tiny_object_storage.api.buckets import router as buckets_router
9
+ from tiny_object_storage.api.health import router as health_router
10
+ from tiny_object_storage.api.objects import router as objects_router
11
+ from tiny_object_storage.config import get_settings
12
+ from tiny_object_storage.core.errors import (
13
+ BucketAlreadyExistsError,
14
+ BucketNotEmptyError,
15
+ BucketNotFoundError,
16
+ InvalidBucketNameError,
17
+ InvalidObjectKeyError,
18
+ ObjectNotFoundError,
19
+ )
20
+ from tiny_object_storage.logging_config import configure_logging, get_logger
21
+ from tiny_object_storage.services.bucket_service import BucketService
22
+ from tiny_object_storage.services.object_service import ObjectService
23
+ from tiny_object_storage.storage.filesystem import FilesystemStorage
24
+
25
+
26
+ @asynccontextmanager
27
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
28
+ # Initialize application resources during startup.
29
+ settings = get_settings()
30
+ configure_logging(log_dir=settings.log_dir, log_level=settings.log_level)
31
+
32
+ storage = FilesystemStorage(base_dir=settings.data_dir)
33
+ bucket_service = BucketService(storage=storage)
34
+ object_service = ObjectService(storage=storage)
35
+
36
+ app.state.settings = settings
37
+ app.state.logger = get_logger("tiny_object_storage.main")
38
+ app.state.storage = storage
39
+ app.state.bucket_service = bucket_service
40
+ app.state.object_service = object_service
41
+
42
+ app.state.logger.info("Starting %s", settings.app_name)
43
+ app.state.logger.info("Host: %s", settings.host)
44
+ app.state.logger.info("Port: %s", settings.port)
45
+ app.state.logger.info("Data directory: %s", settings.data_dir)
46
+ app.state.logger.info("Log directory: %s", settings.log_dir)
47
+ app.state.logger.info("Log level: %s", settings.log_level)
48
+ app.state.logger.info("Dev mode: %s", settings.dev_mode)
49
+
50
+ yield
51
+
52
+ app.state.logger.info("Shutting down %s", settings.app_name)
53
+
54
+
55
+ app = FastAPI(
56
+ title="tiny-object-storage",
57
+ version="0.1.0",
58
+ summary="A lightweight S3-style object storage server.",
59
+ description=(
60
+ "tiny-object-storage is a lightweight S3-style object storage server for local "
61
+ "development, testing, and learning. It provides bucket and object operations, "
62
+ "file-based persistence, structured logging, and OpenAPI documentation."
63
+ ),
64
+ lifespan=lifespan,
65
+ )
66
+
67
+
68
+ @app.middleware("http")
69
+ async def request_logging_middleware(
70
+ request: Request, call_next: Callable[[Request], Awaitable[Response]]
71
+ ) -> Response:
72
+ # Log incoming requests and their response status.
73
+ logger = get_logger("tiny_object_storage.api")
74
+ started_at = time.perf_counter()
75
+
76
+ try:
77
+ response = await call_next(request)
78
+ except Exception:
79
+ duration_ms = (time.perf_counter() - started_at) * 1000
80
+ logger.exception(
81
+ "Unhandled request error method=%s path=%s duration_ms=%.2f",
82
+ request.method,
83
+ request.url.path,
84
+ duration_ms,
85
+ )
86
+ raise
87
+
88
+ duration_ms = (time.perf_counter() - started_at) * 1000
89
+ logger.info(
90
+ "Request completed method=%s path=%s status_code=%s duration_ms=%.2f",
91
+ request.method,
92
+ request.url.path,
93
+ response.status_code,
94
+ duration_ms,
95
+ )
96
+ return response
97
+
98
+
99
+ def _build_xml_error(code: str, message: str) -> str:
100
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
101
+ <Error>
102
+ <Code>{code}</Code>
103
+ <Message>{message}</Message>
104
+ </Error>"""
105
+
106
+
107
+ @app.exception_handler(InvalidBucketNameError)
108
+ async def invalid_bucket_name_handler(request: Request, exc: InvalidBucketNameError) -> Response:
109
+ logger = get_logger("tiny_object_storage.main")
110
+ logger.warning("Invalid bucket name path=%s message=%s", request.url.path, str(exc))
111
+ return Response(
112
+ status_code=400,
113
+ content=_build_xml_error("InvalidBucketName", str(exc)),
114
+ media_type="application/xml",
115
+ )
116
+
117
+
118
+ @app.exception_handler(InvalidObjectKeyError)
119
+ async def invalid_object_key_handler(request: Request, exc: InvalidObjectKeyError) -> Response:
120
+ logger = get_logger("tiny_object_storage.main")
121
+ logger.warning("Invalid object key path=%s message=%s", request.url.path, str(exc))
122
+ return Response(
123
+ status_code=400,
124
+ content=_build_xml_error("InvalidObjectKey", str(exc)),
125
+ media_type="application/xml",
126
+ )
127
+
128
+
129
+ @app.exception_handler(BucketAlreadyExistsError)
130
+ async def bucket_already_exists_handler(
131
+ request: Request, exc: BucketAlreadyExistsError
132
+ ) -> Response:
133
+ logger = get_logger("tiny_object_storage.main")
134
+ logger.warning("Bucket already exists path=%s message=%s", request.url.path, str(exc))
135
+ return Response(
136
+ status_code=409,
137
+ content=_build_xml_error("BucketAlreadyExists", str(exc)),
138
+ media_type="application/xml",
139
+ )
140
+
141
+
142
+ @app.exception_handler(BucketNotFoundError)
143
+ async def bucket_not_found_handler(request: Request, exc: BucketNotFoundError) -> Response:
144
+ logger = get_logger("tiny_object_storage.main")
145
+ logger.warning("Bucket not found path=%s message=%s", request.url.path, str(exc))
146
+ return Response(
147
+ status_code=404,
148
+ content=_build_xml_error("NoSuchBucket", str(exc)),
149
+ media_type="application/xml",
150
+ )
151
+
152
+
153
+ @app.exception_handler(BucketNotEmptyError)
154
+ async def bucket_not_empty_handler(request: Request, exc: BucketNotEmptyError) -> Response:
155
+ logger = get_logger("tiny_object_storage.main")
156
+ logger.warning("Bucket not empty path=%s message=%s", request.url.path, str(exc))
157
+ return Response(
158
+ status_code=409,
159
+ content=_build_xml_error("BucketNotEmpty", str(exc)),
160
+ media_type="application/xml",
161
+ )
162
+
163
+
164
+ @app.exception_handler(ObjectNotFoundError)
165
+ async def object_not_found_handler(request: Request, exc: ObjectNotFoundError) -> Response:
166
+ logger = get_logger("tiny_object_storage.main")
167
+ logger.warning("Object not found path=%s message=%s", request.url.path, str(exc))
168
+ return Response(
169
+ status_code=404,
170
+ content=_build_xml_error("NoSuchKey", str(exc)),
171
+ media_type="application/xml",
172
+ )
173
+
174
+
175
+ @app.exception_handler(ValueError)
176
+ async def value_error_handler(request: Request, exc: ValueError) -> Response:
177
+ logger = get_logger("tiny_object_storage.main")
178
+ logger.warning("Invalid request value path=%s message=%s", request.url.path, str(exc))
179
+ return Response(
180
+ status_code=400,
181
+ content=_build_xml_error("InvalidArgument", str(exc)),
182
+ media_type="application/xml",
183
+ )
184
+
185
+
186
+ @app.exception_handler(Exception)
187
+ async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
188
+ logger = get_logger("tiny_object_storage.main")
189
+ logger.exception("Unhandled application error path=%s", request.url.path)
190
+ return Response(
191
+ status_code=500,
192
+ content=_build_xml_error("InternalError", "An unexpected error occurred."),
193
+ media_type="application/xml",
194
+ )
195
+
196
+
197
+ app.include_router(health_router)
198
+ app.include_router(buckets_router)
199
+ app.include_router(objects_router)
File without changes
@@ -0,0 +1,10 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class BucketInfo(BaseModel):
5
+ # Represent bucket metadata returned by the API.
6
+ name: str = Field(..., description="Bucket name.")
7
+ created_at: str | None = Field(
8
+ default=None,
9
+ description="Bucket creation timestamp in ISO 8601 format.",
10
+ )
@@ -0,0 +1,18 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class ObjectInfo(BaseModel):
5
+ # Represent object metadata returned by the API.
6
+ bucket_name: str = Field(..., description="Bucket name.")
7
+ object_key: str = Field(..., description="Object key.")
8
+ size: int = Field(..., description="Object size in bytes.")
9
+ content_type: str = Field(..., description="Object content type.")
10
+ etag: str = Field(..., description="Entity tag for the stored object.")
11
+ created_at: str | None = Field(
12
+ default=None,
13
+ description="Object creation timestamp in ISO 8601 format.",
14
+ )
15
+ updated_at: str | None = Field(
16
+ default=None,
17
+ description="Object update timestamp in ISO 8601 format.",
18
+ )
@@ -0,0 +1,48 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+ from tiny_object_storage.models.bucket import BucketInfo
4
+ from tiny_object_storage.models.object_info import ObjectInfo
5
+
6
+
7
+ class HealthResponse(BaseModel):
8
+ # Represent healthcheck response payload.
9
+ status: str = Field(..., description="Service status.")
10
+ app_name: str = Field(..., description="Application name.")
11
+ data_dir: str = Field(..., description="Configured storage data directory.")
12
+ log_dir: str = Field(..., description="Configured log directory.")
13
+ log_level: str = Field(..., description="Configured log level.")
14
+ dev_mode: bool = Field(..., description="Whether dev mode is enabled.")
15
+
16
+
17
+ class MessageResponse(BaseModel):
18
+ # Represent a generic API message response.
19
+ message: str = Field(..., description="Human-readable status message.")
20
+
21
+
22
+ class ErrorResponse(BaseModel):
23
+ # Represent a standardized error response.
24
+ error: str = Field(..., description="Machine-readable error code.")
25
+ message: str = Field(..., description="Human-readable error message.")
26
+
27
+
28
+ class BucketsListResponse(BaseModel):
29
+ # Represent bucket listing response.
30
+ buckets: list[BucketInfo] = Field(..., description="List of buckets.")
31
+
32
+
33
+ class ObjectsListResponse(BaseModel):
34
+ # Represent object listing response.
35
+ bucket_name: str = Field(..., description="Bucket name.")
36
+ objects: list[ObjectInfo] = Field(..., description="List of objects.")
37
+
38
+
39
+ class BucketCreatedResponse(BaseModel):
40
+ # Represent bucket creation response.
41
+ message: str = Field(..., description="Human-readable status message.")
42
+ bucket: BucketInfo = Field(..., description="Created bucket metadata.")
43
+
44
+
45
+ class ObjectStoredResponse(BaseModel):
46
+ # Represent object upload response.
47
+ message: str = Field(..., description="Human-readable status message.")
48
+ object: ObjectInfo = Field(..., description="Stored object metadata.")
File without changes
@@ -0,0 +1,50 @@
1
+ from typing import Any
2
+
3
+ from tiny_object_storage.logging_config import get_logger
4
+ from tiny_object_storage.storage.base import BaseStorage
5
+
6
+
7
+ class BucketService:
8
+ # Handle bucket-related business logic.
9
+
10
+ def __init__(self, storage: BaseStorage):
11
+ # Store the configured storage backend.
12
+ self.storage = storage
13
+ self.logger = get_logger("tiny_object_storage.services.bucket")
14
+
15
+ def create_bucket(self, bucket_name: str) -> dict[str, Any]:
16
+ # Create a bucket and return its metadata.
17
+ self.logger.info("Creating bucket bucket=%s", bucket_name)
18
+ self.storage.create_bucket(bucket_name)
19
+
20
+ for bucket in self.storage.list_buckets():
21
+ if bucket["name"] == bucket_name:
22
+ self.logger.info("Bucket created bucket=%s", bucket_name)
23
+ return bucket
24
+
25
+ result = {
26
+ "name": bucket_name,
27
+ "created_at": None,
28
+ }
29
+ self.logger.info("Bucket created bucket=%s", bucket_name)
30
+ return result
31
+
32
+ def delete_bucket(self, bucket_name: str) -> None:
33
+ # Delete an existing bucket.
34
+ self.logger.info("Deleting bucket bucket=%s", bucket_name)
35
+ self.storage.delete_bucket(bucket_name)
36
+ self.logger.info("Bucket deleted bucket=%s", bucket_name)
37
+
38
+ def list_buckets(self) -> list[dict[str, Any]]:
39
+ # Return all buckets from the storage backend.
40
+ self.logger.info("Listing buckets")
41
+ buckets = self.storage.list_buckets()
42
+ self.logger.info("Buckets listed count=%s", len(buckets))
43
+ return buckets
44
+
45
+ def bucket_exists(self, bucket_name: str) -> bool:
46
+ # Return whether the given bucket exists.
47
+ self.logger.debug("Checking bucket existence bucket=%s", bucket_name)
48
+ exists = self.storage.bucket_exists(bucket_name)
49
+ self.logger.debug("Bucket existence checked bucket=%s exists=%s", bucket_name, exists)
50
+ return exists