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,8 @@
1
+ import importlib.metadata
2
+
3
+ try:
4
+ __version__ = importlib.metadata.version("tiny-object-storage")
5
+ except importlib.metadata.PackageNotFoundError:
6
+ __version__ = "unknown"
7
+
8
+ __all__ = ["__version__"]
File without changes
@@ -0,0 +1,114 @@
1
+ from fastapi import APIRouter, Request, Response, status
2
+ from fastapi.responses import Response as FastAPIResponse
3
+
4
+ from tiny_object_storage.core.utils import build_list_buckets_xml
5
+ from tiny_object_storage.models.bucket import BucketInfo
6
+ from tiny_object_storage.models.responses import (
7
+ BucketCreatedResponse,
8
+ BucketsListResponse,
9
+ ErrorResponse,
10
+ )
11
+
12
+ router = APIRouter(tags=["buckets"])
13
+
14
+
15
+ @router.get(
16
+ "/",
17
+ responses={
18
+ 200: {
19
+ "description": "Buckets returned in S3-style XML.",
20
+ "content": {"application/xml": {}},
21
+ },
22
+ 500: {"model": ErrorResponse, "description": "Internal server error."},
23
+ },
24
+ summary="List buckets",
25
+ description="Return all available buckets as a minimal S3-compatible XML response.",
26
+ )
27
+ def list_buckets(request: Request) -> FastAPIResponse:
28
+ # Return all buckets from the service layer as XML.
29
+ bucket_service = request.app.state.bucket_service
30
+ buckets = bucket_service.list_buckets()
31
+ xml_payload = build_list_buckets_xml(buckets)
32
+ return FastAPIResponse(content=xml_payload, media_type="application/xml")
33
+
34
+
35
+ @router.get(
36
+ "/_debug/buckets",
37
+ response_model=BucketsListResponse,
38
+ include_in_schema=True,
39
+ summary="List buckets as JSON",
40
+ description="Return all available buckets in JSON format for debugging and development.",
41
+ responses={
42
+ 500: {"model": ErrorResponse, "description": "Internal server error."},
43
+ },
44
+ )
45
+ def list_buckets_json(request: Request) -> BucketsListResponse:
46
+ # Return all buckets from the service layer as JSON.
47
+ bucket_service = request.app.state.bucket_service
48
+ buckets = bucket_service.list_buckets()
49
+ return BucketsListResponse(buckets=[BucketInfo(**bucket) for bucket in buckets])
50
+
51
+
52
+ @router.put(
53
+ "/{bucket_name}",
54
+ response_model=BucketCreatedResponse,
55
+ status_code=status.HTTP_200_OK,
56
+ summary="Create bucket",
57
+ description="Create a new bucket using an S3-style path.",
58
+ responses={
59
+ 200: {"description": "Bucket created."},
60
+ 400: {"model": ErrorResponse, "description": "Invalid bucket name."},
61
+ 409: {"model": ErrorResponse, "description": "Bucket already exists."},
62
+ 500: {"model": ErrorResponse, "description": "Internal server error."},
63
+ },
64
+ )
65
+ def create_bucket(bucket_name: str, request: Request) -> BucketCreatedResponse:
66
+ # Create a new bucket and return its metadata.
67
+ bucket_service = request.app.state.bucket_service
68
+ bucket = bucket_service.create_bucket(bucket_name=bucket_name)
69
+ return BucketCreatedResponse(
70
+ message="Bucket created successfully.",
71
+ bucket=BucketInfo(**bucket),
72
+ )
73
+
74
+
75
+ @router.delete(
76
+ "/{bucket_name}",
77
+ status_code=status.HTTP_204_NO_CONTENT,
78
+ summary="Delete bucket",
79
+ description="Delete an existing bucket. The bucket must be empty.",
80
+ responses={
81
+ 204: {"description": "Bucket deleted."},
82
+ 400: {"model": ErrorResponse, "description": "Invalid bucket name."},
83
+ 404: {"model": ErrorResponse, "description": "Bucket not found."},
84
+ 409: {"model": ErrorResponse, "description": "Bucket is not empty."},
85
+ 500: {"model": ErrorResponse, "description": "Internal server error."},
86
+ },
87
+ )
88
+ def delete_bucket(bucket_name: str, request: Request) -> Response:
89
+ # Delete the requested bucket.
90
+ bucket_service = request.app.state.bucket_service
91
+ bucket_service.delete_bucket(bucket_name=bucket_name)
92
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
93
+
94
+
95
+ @router.head(
96
+ "/{bucket_name}",
97
+ status_code=status.HTTP_200_OK,
98
+ summary="Head bucket",
99
+ description="Determine if a bucket exists and if you have permission to access it.",
100
+ responses={
101
+ 200: {"description": "Bucket exists."},
102
+ 400: {"model": ErrorResponse, "description": "Invalid bucket name."},
103
+ 404: {"model": ErrorResponse, "description": "Bucket not found."},
104
+ 500: {"model": ErrorResponse, "description": "Internal server error."},
105
+ },
106
+ )
107
+ def head_bucket(bucket_name: str, request: Request) -> Response:
108
+ # Check if a bucket exists. Boto3 relies on this to verify buckets before acting.
109
+ bucket_service = request.app.state.bucket_service
110
+ from tiny_object_storage.core.errors import BucketNotFoundError
111
+
112
+ if not bucket_service.bucket_exists(bucket_name):
113
+ raise BucketNotFoundError(f"Bucket '{bucket_name}' does not exist.")
114
+ return Response(status_code=status.HTTP_200_OK)
@@ -0,0 +1,25 @@
1
+ from fastapi import APIRouter, Request
2
+
3
+ from tiny_object_storage.models.responses import HealthResponse
4
+
5
+ router = APIRouter(tags=["health"])
6
+
7
+
8
+ @router.get(
9
+ "/health",
10
+ response_model=HealthResponse,
11
+ summary="Health check",
12
+ description="Return the current service health status and active runtime configuration.",
13
+ )
14
+ def healthcheck(request: Request) -> HealthResponse:
15
+ # Return a simple health status payload.
16
+ settings = request.app.state.settings
17
+
18
+ return HealthResponse(
19
+ status="ok",
20
+ app_name=settings.app_name,
21
+ data_dir=str(settings.data_dir),
22
+ log_dir=str(settings.log_dir),
23
+ log_level=settings.log_level,
24
+ dev_mode=settings.dev_mode,
25
+ )
@@ -0,0 +1,196 @@
1
+ from datetime import UTC, datetime
2
+ from io import BytesIO
3
+
4
+ from fastapi import APIRouter, Header, Query, Request, Response, status
5
+ from fastapi.responses import StreamingResponse
6
+
7
+ from tiny_object_storage.core.utils import build_list_objects_v2_xml, http_date
8
+ from tiny_object_storage.models.object_info import ObjectInfo
9
+ from tiny_object_storage.models.responses import (
10
+ ErrorResponse,
11
+ ObjectsListResponse,
12
+ )
13
+
14
+ router = APIRouter(tags=["objects"])
15
+
16
+
17
+ @router.get(
18
+ "/{bucket_name}",
19
+ responses={
20
+ 200: {
21
+ "description": "Objects returned in minimal S3 ListObjectsV2 XML format.",
22
+ "content": {"application/xml": {}},
23
+ },
24
+ 400: {"model": ErrorResponse, "description": "Invalid bucket name or query parameter."},
25
+ 404: {"model": ErrorResponse, "description": "Bucket not found."},
26
+ 500: {"model": ErrorResponse, "description": "Internal server error."},
27
+ },
28
+ summary="List objects",
29
+ description="Return objects in a bucket using a minimal S3 ListObjectsV2 XML response.",
30
+ )
31
+ def list_objects(
32
+ bucket_name: str,
33
+ request: Request,
34
+ list_type: int = Query(
35
+ default=2,
36
+ alias="list-type",
37
+ description="S3-style listing mode. Only value 2 is currently supported.",
38
+ ),
39
+ prefix: str = Query(default="", description="Optional key prefix filter."),
40
+ max_keys: int = Query(default=1000, alias="max-keys", description="Maximum number of keys."),
41
+ ) -> Response:
42
+ # Return bucket contents in minimal S3-compatible XML.
43
+ if list_type != 2:
44
+ raise ValueError("Only list-type=2 is supported.")
45
+
46
+ object_service = request.app.state.object_service
47
+ objects = object_service.list_objects(bucket_name=bucket_name)
48
+
49
+ if prefix:
50
+ objects = [item for item in objects if item["object_key"].startswith(prefix)]
51
+
52
+ limited = objects[:max_keys]
53
+ truncated = len(objects) > max_keys
54
+
55
+ xml_payload = build_list_objects_v2_xml(
56
+ bucket_name=bucket_name,
57
+ objects=limited,
58
+ prefix=prefix,
59
+ max_keys=max_keys,
60
+ is_truncated=truncated,
61
+ )
62
+ return Response(content=xml_payload, media_type="application/xml")
63
+
64
+
65
+ @router.get(
66
+ "/_debug/{bucket_name}",
67
+ response_model=ObjectsListResponse,
68
+ include_in_schema=True,
69
+ summary="List objects as JSON",
70
+ description="Return objects in a bucket as JSON for debugging and development.",
71
+ responses={
72
+ 400: {"model": ErrorResponse, "description": "Invalid bucket name or query parameter."},
73
+ 404: {"model": ErrorResponse, "description": "Bucket not found."},
74
+ 500: {"model": ErrorResponse, "description": "Internal server error."},
75
+ },
76
+ )
77
+ def list_objects_json(bucket_name: str, request: Request) -> ObjectsListResponse:
78
+ # Return all objects in the requested bucket as JSON.
79
+ object_service = request.app.state.object_service
80
+ objects = object_service.list_objects(bucket_name=bucket_name)
81
+ return ObjectsListResponse(
82
+ bucket_name=bucket_name,
83
+ objects=[ObjectInfo(**item) for item in objects],
84
+ )
85
+
86
+
87
+ @router.put(
88
+ "/{bucket_name}/{object_key:path}",
89
+ status_code=status.HTTP_200_OK,
90
+ summary="Upload object",
91
+ description="Store an object in a bucket using an S3-style path and a raw request body.",
92
+ responses={
93
+ 200: {"description": "Object stored."},
94
+ 400: {"model": ErrorResponse, "description": "Invalid input."},
95
+ 404: {"model": ErrorResponse, "description": "Bucket not found."},
96
+ 500: {"model": ErrorResponse, "description": "Internal server error."},
97
+ },
98
+ )
99
+ async def put_object(
100
+ bucket_name: str,
101
+ object_key: str,
102
+ request: Request,
103
+ content_type: str | None = Header(default=None, alias="Content-Type"),
104
+ ) -> Response:
105
+ # Upload an object using a raw request body.
106
+ body = await request.body()
107
+ object_service = request.app.state.object_service
108
+ metadata = object_service.put_object(
109
+ bucket_name=bucket_name,
110
+ object_key=object_key,
111
+ data=body,
112
+ content_type=content_type,
113
+ )
114
+ headers = {"ETag": f"\"{metadata['etag']}\""}
115
+ return Response(status_code=status.HTTP_200_OK, headers=headers)
116
+
117
+
118
+ @router.head(
119
+ "/{bucket_name}/{object_key:path}",
120
+ status_code=status.HTTP_200_OK,
121
+ summary="Head object",
122
+ description="Return object metadata headers without returning the object body.",
123
+ responses={
124
+ 200: {"description": "Object metadata returned in headers."},
125
+ 400: {"model": ErrorResponse, "description": "Invalid input."},
126
+ 404: {"model": ErrorResponse, "description": "Bucket or object not found."},
127
+ 500: {"model": ErrorResponse, "description": "Internal server error."},
128
+ },
129
+ )
130
+ def head_object(bucket_name: str, object_key: str, request: Request) -> Response:
131
+ # Return object metadata using standard S3-style headers.
132
+ object_service = request.app.state.object_service
133
+ stored_object = object_service.get_object(bucket_name=bucket_name, object_key=object_key)
134
+
135
+ headers = {
136
+ "ETag": f"\"{stored_object.metadata['etag']}\"",
137
+ "Content-Length": str(stored_object.metadata["size"]),
138
+ "Content-Type": stored_object.metadata["content_type"],
139
+ "Last-Modified": http_date(stored_object.metadata.get("updated_at"))
140
+ or format_fallback_http_date(),
141
+ }
142
+ return Response(status_code=status.HTTP_200_OK, headers=headers)
143
+
144
+
145
+ @router.get(
146
+ "/{bucket_name}/{object_key:path}",
147
+ summary="Download object",
148
+ description="Download a stored object using an S3-style path.",
149
+ responses={
150
+ 200: {"description": "Object returned."},
151
+ 400: {"model": ErrorResponse, "description": "Invalid input."},
152
+ 404: {"model": ErrorResponse, "description": "Bucket or object not found."},
153
+ 500: {"model": ErrorResponse, "description": "Internal server error."},
154
+ },
155
+ )
156
+ def get_object(bucket_name: str, object_key: str, request: Request) -> StreamingResponse:
157
+ # Stream the stored object back to the client.
158
+ object_service = request.app.state.object_service
159
+ stored_object = object_service.get_object(bucket_name=bucket_name, object_key=object_key)
160
+
161
+ headers = {
162
+ "ETag": f"\"{stored_object.metadata['etag']}\"",
163
+ "Content-Length": str(stored_object.metadata["size"]),
164
+ "Last-Modified": http_date(stored_object.metadata.get("updated_at"))
165
+ or format_fallback_http_date(),
166
+ }
167
+
168
+ return StreamingResponse(
169
+ content=BytesIO(stored_object.data),
170
+ media_type=stored_object.metadata["content_type"],
171
+ headers=headers,
172
+ )
173
+
174
+
175
+ @router.delete(
176
+ "/{bucket_name}/{object_key:path}",
177
+ status_code=status.HTTP_204_NO_CONTENT,
178
+ summary="Delete object",
179
+ description="Delete an object from a bucket using an S3-style path.",
180
+ responses={
181
+ 204: {"description": "Object deleted."},
182
+ 400: {"model": ErrorResponse, "description": "Invalid input."},
183
+ 404: {"model": ErrorResponse, "description": "Bucket or object not found."},
184
+ 500: {"model": ErrorResponse, "description": "Internal server error."},
185
+ },
186
+ )
187
+ def delete_object(bucket_name: str, object_key: str, request: Request) -> Response:
188
+ # Delete the requested object.
189
+ object_service = request.app.state.object_service
190
+ object_service.delete_object(bucket_name=bucket_name, object_key=object_key)
191
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
192
+
193
+
194
+ def format_fallback_http_date() -> str:
195
+ # Return the current UTC time formatted for HTTP headers.
196
+ return datetime.now(UTC).strftime("%a, %d %b %Y %H:%M:%S GMT")
@@ -0,0 +1,128 @@
1
+ import argparse
2
+ import os
3
+ from pathlib import Path
4
+
5
+ import uvicorn
6
+ from dotenv import load_dotenv
7
+
8
+ from tiny_object_storage.config import get_settings
9
+
10
+
11
+ def str_to_bool(value: str) -> bool:
12
+ # Convert common string values to boolean.
13
+ normalized = value.strip().lower()
14
+ if normalized in {"1", "true", "yes", "y", "on"}:
15
+ return True
16
+ if normalized in {"0", "false", "no", "n", "off"}:
17
+ return False
18
+ raise argparse.ArgumentTypeError(f"Invalid boolean value: {value}")
19
+
20
+
21
+ def build_parser() -> argparse.ArgumentParser:
22
+ # Build the CLI argument parser.
23
+ parser = argparse.ArgumentParser(
24
+ prog="tos-server",
25
+ description="Run the tiny-object-storage server.",
26
+ )
27
+
28
+ parser.add_argument(
29
+ "--env-file",
30
+ default=".env",
31
+ help="Path to the .env file to load before starting the server.",
32
+ )
33
+ parser.add_argument(
34
+ "--host",
35
+ help="Override server host.",
36
+ )
37
+ parser.add_argument(
38
+ "--port",
39
+ type=int,
40
+ help="Override server port.",
41
+ )
42
+ parser.add_argument(
43
+ "--log-level",
44
+ choices=["critical", "error", "warning", "info", "debug", "trace"],
45
+ help="Override uvicorn log level.",
46
+ )
47
+ parser.add_argument(
48
+ "--reload",
49
+ action="store_true",
50
+ help="Enable auto-reload.",
51
+ )
52
+ parser.add_argument(
53
+ "--no-reload",
54
+ action="store_true",
55
+ help="Disable auto-reload even if dev mode is enabled.",
56
+ )
57
+ parser.add_argument(
58
+ "--data-dir",
59
+ help="Override storage data directory.",
60
+ )
61
+ parser.add_argument(
62
+ "--log-dir",
63
+ help="Override application log directory.",
64
+ )
65
+ parser.add_argument(
66
+ "--dev-mode",
67
+ type=str_to_bool,
68
+ help="Override development mode.",
69
+ )
70
+ parser.add_argument(
71
+ "--max-object-size",
72
+ type=int,
73
+ help="Override maximum object size in bytes.",
74
+ )
75
+
76
+ return parser
77
+
78
+
79
+ def main() -> None:
80
+ # Load configuration sources, apply CLI overrides, and start the server.
81
+ parser = build_parser()
82
+ args = parser.parse_args()
83
+
84
+ env_file = Path(args.env_file)
85
+ if env_file.exists():
86
+ load_dotenv(dotenv_path=env_file, override=False)
87
+
88
+ if args.host is not None:
89
+ os.environ["TOS_HOST"] = args.host
90
+
91
+ if args.port is not None:
92
+ os.environ["TOS_PORT"] = str(args.port)
93
+
94
+ if args.data_dir is not None:
95
+ os.environ["TOS_DATA_DIR"] = args.data_dir
96
+
97
+ if args.log_dir is not None:
98
+ os.environ["TOS_LOG_DIR"] = args.log_dir
99
+
100
+ if args.dev_mode is not None:
101
+ os.environ["TOS_DEV_MODE"] = "true" if args.dev_mode else "false"
102
+
103
+ if args.max_object_size is not None:
104
+ os.environ["TOS_MAX_OBJECT_SIZE"] = str(args.max_object_size)
105
+
106
+ if args.log_level is not None:
107
+ os.environ["TOS_LOG_LEVEL"] = args.log_level.upper()
108
+
109
+ get_settings.cache_clear()
110
+ settings = get_settings()
111
+
112
+ reload_enabled = settings.dev_mode
113
+ if args.reload:
114
+ reload_enabled = True
115
+ if args.no_reload:
116
+ reload_enabled = False
117
+
118
+ uvicorn.run(
119
+ "tiny_object_storage.main:app",
120
+ host=settings.host,
121
+ port=settings.port,
122
+ reload=reload_enabled,
123
+ log_level=settings.log_level.lower(),
124
+ )
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
@@ -0,0 +1,37 @@
1
+ from functools import lru_cache
2
+ from pathlib import Path
3
+
4
+ from pydantic import Field
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+
8
+ class Settings(BaseSettings):
9
+ # Runtime settings for the application.
10
+ app_name: str = Field(default="tiny-object-storage", alias="TOS_APP_NAME")
11
+ host: str = Field(default="0.0.0.0", alias="TOS_HOST")
12
+ port: int = Field(default=9000, alias="TOS_PORT")
13
+ data_dir: Path = Field(default=Path("./storage_data"), alias="TOS_DATA_DIR")
14
+ log_dir: Path = Field(default=Path("./logs"), alias="TOS_LOG_DIR")
15
+ log_level: str = Field(default="INFO", alias="TOS_LOG_LEVEL")
16
+ dev_mode: bool = Field(default=True, alias="TOS_DEV_MODE")
17
+ max_object_size: int = Field(default=104857600, alias="TOS_MAX_OBJECT_SIZE")
18
+
19
+ model_config = SettingsConfigDict(
20
+ env_file=".env",
21
+ env_file_encoding="utf-8",
22
+ extra="ignore",
23
+ populate_by_name=True,
24
+ )
25
+
26
+ def ensure_directories(self) -> None:
27
+ # Create required directories if they do not exist.
28
+ self.data_dir.mkdir(parents=True, exist_ok=True)
29
+ self.log_dir.mkdir(parents=True, exist_ok=True)
30
+
31
+
32
+ @lru_cache
33
+ def get_settings() -> Settings:
34
+ # Return cached application settings.
35
+ settings = Settings()
36
+ settings.ensure_directories()
37
+ return settings
File without changes
@@ -0,0 +1,33 @@
1
+ class TinyObjectStorageError(Exception):
2
+ # Base exception for all domain-specific errors.
3
+ pass
4
+
5
+
6
+ class BucketAlreadyExistsError(TinyObjectStorageError):
7
+ # Raised when trying to create a bucket that already exists.
8
+ pass
9
+
10
+
11
+ class BucketNotFoundError(TinyObjectStorageError):
12
+ # Raised when the target bucket does not exist.
13
+ pass
14
+
15
+
16
+ class BucketNotEmptyError(TinyObjectStorageError):
17
+ # Raised when trying to delete a non-empty bucket.
18
+ pass
19
+
20
+
21
+ class ObjectNotFoundError(TinyObjectStorageError):
22
+ # Raised when the target object does not exist.
23
+ pass
24
+
25
+
26
+ class InvalidBucketNameError(TinyObjectStorageError):
27
+ # Raised when a bucket name does not meet validation rules.
28
+ pass
29
+
30
+
31
+ class InvalidObjectKeyError(TinyObjectStorageError):
32
+ # Raised when an object key does not meet validation rules.
33
+ pass