stellarmesh-objectstorage 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.
- stellarmesh_objectstorage/__init__.py +39 -0
- stellarmesh_objectstorage/_files.py +44 -0
- stellarmesh_objectstorage/_requests.py +145 -0
- stellarmesh_objectstorage/async_client.py +312 -0
- stellarmesh_objectstorage/client.py +270 -0
- stellarmesh_objectstorage/config.py +119 -0
- stellarmesh_objectstorage/errors.py +71 -0
- stellarmesh_objectstorage/multipart.py +19 -0
- stellarmesh_objectstorage/objects.py +116 -0
- stellarmesh_objectstorage/presign.py +18 -0
- stellarmesh_objectstorage/py.typed +0 -0
- stellarmesh_objectstorage-0.1.0.dist-info/METADATA +41 -0
- stellarmesh_objectstorage-0.1.0.dist-info/RECORD +16 -0
- stellarmesh_objectstorage-0.1.0.dist-info/WHEEL +5 -0
- stellarmesh_objectstorage-0.1.0.dist-info/licenses/LICENSE +21 -0
- stellarmesh_objectstorage-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Python 进程内 S3/MinIO 客户端;业务配置、权限与资源部署由调用方负责。"""
|
|
2
|
+
|
|
3
|
+
from .async_client import AsyncClient
|
|
4
|
+
from .client import Client
|
|
5
|
+
from .config import ClientConfig
|
|
6
|
+
from .errors import (
|
|
7
|
+
ClientClosedError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
ForbiddenError,
|
|
10
|
+
InvalidRequestError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
PreconditionFailedError,
|
|
13
|
+
StorageError,
|
|
14
|
+
UnavailableError,
|
|
15
|
+
)
|
|
16
|
+
from .multipart import CompletedPart, MultipartUpload
|
|
17
|
+
from .objects import AsyncObjectStream, ObjectInfo, ObjectStream, WriteResult
|
|
18
|
+
from .presign import PresignedRequest
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AsyncClient",
|
|
22
|
+
"AsyncObjectStream",
|
|
23
|
+
"Client",
|
|
24
|
+
"ClientClosedError",
|
|
25
|
+
"ClientConfig",
|
|
26
|
+
"CompletedPart",
|
|
27
|
+
"ConflictError",
|
|
28
|
+
"ForbiddenError",
|
|
29
|
+
"InvalidRequestError",
|
|
30
|
+
"MultipartUpload",
|
|
31
|
+
"NotFoundError",
|
|
32
|
+
"ObjectInfo",
|
|
33
|
+
"ObjectStream",
|
|
34
|
+
"PreconditionFailedError",
|
|
35
|
+
"PresignedRequest",
|
|
36
|
+
"StorageError",
|
|
37
|
+
"UnavailableError",
|
|
38
|
+
"WriteResult",
|
|
39
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""文件传输的原子提交与取消隔离,不改变网络取消语义。"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import tempfile
|
|
5
|
+
from collections.abc import Callable, Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import BinaryIO, TypeVar, cast
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@contextmanager
|
|
14
|
+
def atomic_destination(destination: str | Path) -> Iterator[tuple[BinaryIO, Path]]:
|
|
15
|
+
path = Path(destination)
|
|
16
|
+
# 同目录 rename 保持原子性;失败和 BaseException(包括取消)都清理暂存。
|
|
17
|
+
with tempfile.NamedTemporaryFile(
|
|
18
|
+
dir=path.parent, prefix=f".{path.name}.", delete=False
|
|
19
|
+
) as output:
|
|
20
|
+
temporary = Path(output.name)
|
|
21
|
+
try:
|
|
22
|
+
yield cast(BinaryIO, output), temporary
|
|
23
|
+
output.close()
|
|
24
|
+
temporary.replace(path)
|
|
25
|
+
finally:
|
|
26
|
+
temporary.unlink(missing_ok=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def file_io(operation: Callable[[], T]) -> T:
|
|
30
|
+
# 线程中的文件读写不可强制取消,先等其结束再释放文件;不用于网络请求。
|
|
31
|
+
task = asyncio.create_task(asyncio.to_thread(operation))
|
|
32
|
+
canceled: asyncio.CancelledError | None = None
|
|
33
|
+
while not task.done():
|
|
34
|
+
try:
|
|
35
|
+
await asyncio.shield(task)
|
|
36
|
+
except asyncio.CancelledError as error:
|
|
37
|
+
canceled = error
|
|
38
|
+
except Exception:
|
|
39
|
+
break
|
|
40
|
+
if canceled is not None:
|
|
41
|
+
if not task.cancelled():
|
|
42
|
+
task.exception()
|
|
43
|
+
raise canceled
|
|
44
|
+
return task.result()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""同步与异步客户端共享纯参数准备;不持有网络或刷新状态。"""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .config import ClientConfig, text
|
|
9
|
+
from .errors import InvalidRequestError
|
|
10
|
+
from .multipart import CompletedPart
|
|
11
|
+
from .objects import MAX_SINGLE_PUT_BYTES
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def connection_options(config: ClientConfig) -> dict[str, Any]:
|
|
15
|
+
return {
|
|
16
|
+
"region_name": config.region,
|
|
17
|
+
"endpoint_url": config.endpoint,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def transport_options(config: ClientConfig) -> dict[str, Any]:
|
|
22
|
+
return {
|
|
23
|
+
"signature_version": "s3v4",
|
|
24
|
+
"connect_timeout": config.connect_timeout,
|
|
25
|
+
"read_timeout": config.read_timeout,
|
|
26
|
+
"retries": {"mode": "standard", "total_max_attempts": config.max_attempts},
|
|
27
|
+
"s3": {"addressing_style": "path" if config.use_path_style else "virtual"},
|
|
28
|
+
# 使用各厂商都支持的必要校验;不隐式为普通上传启用 aws-chunked。
|
|
29
|
+
"request_checksum_calculation": "when_required",
|
|
30
|
+
"response_checksum_validation": "when_required",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def object_request(
|
|
35
|
+
config: ClientConfig, key: str, version_id: str | None = None
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
result: dict[str, Any] = {"Bucket": config.bucket, "Key": config.physical_key(key)}
|
|
38
|
+
if version_id is not None:
|
|
39
|
+
result["VersionId"] = text(version_id, "version_id")
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def upload_fields(
|
|
44
|
+
content_type: str | None, metadata: Mapping[str, str] | None
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
result: dict[str, Any] = {}
|
|
47
|
+
if content_type is not None:
|
|
48
|
+
result["ContentType"] = text(content_type, "content_type")
|
|
49
|
+
if metadata is not None:
|
|
50
|
+
copied = {}
|
|
51
|
+
for key, value in metadata.items():
|
|
52
|
+
text(key, "metadata key")
|
|
53
|
+
text(value, "metadata value", empty=True)
|
|
54
|
+
# HTTP 头字段名大小写不敏感,避免签名与实际传输出现两个来源。
|
|
55
|
+
lowered = key.lower()
|
|
56
|
+
if lowered in copied:
|
|
57
|
+
raise InvalidRequestError("metadata 字段名不能大小写重复")
|
|
58
|
+
try:
|
|
59
|
+
key.encode("ascii")
|
|
60
|
+
value.encode("ascii")
|
|
61
|
+
except UnicodeError as error:
|
|
62
|
+
raise InvalidRequestError("S3 metadata 必须使用 ASCII") from error
|
|
63
|
+
if any(
|
|
64
|
+
char not in "!#$%&'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyz"
|
|
65
|
+
for char in lowered
|
|
66
|
+
):
|
|
67
|
+
raise InvalidRequestError("metadata 字段名不是合法 HTTP token")
|
|
68
|
+
copied[lowered] = value
|
|
69
|
+
result["Metadata"] = copied
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def size_value(size: int) -> int:
|
|
74
|
+
if type(size) is not int or not 0 <= size <= MAX_SINGLE_PUT_BYTES:
|
|
75
|
+
raise InvalidRequestError("单次上传大小须在 0~5 GiB;较大对象请使用 Multipart")
|
|
76
|
+
return size
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def upload_request(
|
|
80
|
+
config: ClientConfig,
|
|
81
|
+
key: str,
|
|
82
|
+
size: int,
|
|
83
|
+
content_type: str | None,
|
|
84
|
+
metadata: Mapping[str, str] | None,
|
|
85
|
+
checksum_sha256: str | None = None,
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
checksum = {}
|
|
88
|
+
if checksum_sha256 is not None:
|
|
89
|
+
try:
|
|
90
|
+
digest = base64.b64decode(checksum_sha256, validate=True)
|
|
91
|
+
except (ValueError, TypeError, binascii.Error) as error:
|
|
92
|
+
raise InvalidRequestError(
|
|
93
|
+
"checksum_sha256 必须是 base64 SHA-256"
|
|
94
|
+
) from error
|
|
95
|
+
if len(digest) != 32:
|
|
96
|
+
raise InvalidRequestError("checksum_sha256 必须是 32 字节摘要")
|
|
97
|
+
checksum["ChecksumSHA256"] = checksum_sha256
|
|
98
|
+
return {
|
|
99
|
+
**checksum,
|
|
100
|
+
**object_request(config, key),
|
|
101
|
+
"ContentLength": size_value(size),
|
|
102
|
+
**upload_fields(content_type, metadata),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def multipart_request(config: ClientConfig, key: str, upload_id: str) -> dict[str, Any]:
|
|
107
|
+
return {**object_request(config, key), "UploadId": text(upload_id, "upload_id")}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def part_number(value: int) -> int:
|
|
111
|
+
if type(value) is not int or not 1 <= value <= 10000:
|
|
112
|
+
raise InvalidRequestError("分片编号必须在 1~10000 之间")
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def completed_parts(parts: Sequence[CompletedPart]) -> dict[str, Any]:
|
|
117
|
+
if not parts:
|
|
118
|
+
raise InvalidRequestError("完成分片列表不能为空")
|
|
119
|
+
seen: set[int] = set()
|
|
120
|
+
result = []
|
|
121
|
+
for part in parts:
|
|
122
|
+
number = part_number(part.part_number)
|
|
123
|
+
if number in seen:
|
|
124
|
+
raise InvalidRequestError("分片编号不能重复")
|
|
125
|
+
seen.add(number)
|
|
126
|
+
etag = text(part.etag, "etag")
|
|
127
|
+
if not etag.strip():
|
|
128
|
+
raise InvalidRequestError("etag 不能为空白")
|
|
129
|
+
result.append({"PartNumber": number, "ETag": etag})
|
|
130
|
+
return {"Parts": sorted(result, key=lambda part: part["PartNumber"])}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def signed_headers(params: dict[str, Any]) -> dict[str, str]:
|
|
134
|
+
headers = (
|
|
135
|
+
{"Content-Length": str(params["ContentLength"])}
|
|
136
|
+
if "ContentLength" in params
|
|
137
|
+
else {}
|
|
138
|
+
)
|
|
139
|
+
if "ChecksumSHA256" in params:
|
|
140
|
+
headers["x-amz-checksum-sha256"] = params["ChecksumSHA256"]
|
|
141
|
+
if "ContentType" in params:
|
|
142
|
+
headers["Content-Type"] = params["ContentType"]
|
|
143
|
+
for key, value in params.get("Metadata", {}).items():
|
|
144
|
+
headers["x-amz-meta-" + key] = value
|
|
145
|
+
return headers
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""复用连接的 aioboto3 异步客户端。"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncIterator, Mapping, Sequence
|
|
5
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from functools import partial
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
import aioboto3
|
|
13
|
+
from aiobotocore.config import AioConfig as Config
|
|
14
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
15
|
+
|
|
16
|
+
from . import _requests as requests
|
|
17
|
+
from ._files import atomic_destination, file_io
|
|
18
|
+
from .config import ClientConfig
|
|
19
|
+
from .errors import ClientClosedError, InvalidRequestError, provider_error
|
|
20
|
+
from .multipart import CompletedPart, MultipartUpload
|
|
21
|
+
from .objects import (
|
|
22
|
+
AsyncObjectStream,
|
|
23
|
+
ObjectInfo,
|
|
24
|
+
WriteResult,
|
|
25
|
+
object_info,
|
|
26
|
+
write_result,
|
|
27
|
+
)
|
|
28
|
+
from .presign import PresignedRequest
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AsyncClient:
|
|
32
|
+
"""通过 async with 打开的 aioboto3 客户端;在同一事件循环内复用。
|
|
33
|
+
|
|
34
|
+
只关闭自己创建的底层连接,不关闭注入的 Session。必须在生命周期入口打开,
|
|
35
|
+
不在业务调用中懒初始化;关闭前由调用方停止在途请求和对象流。
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self, config: ClientConfig, *, session: aioboto3.Session | None = None
|
|
40
|
+
) -> None:
|
|
41
|
+
self._config = config
|
|
42
|
+
self._session = session
|
|
43
|
+
self._stack = AsyncExitStack()
|
|
44
|
+
self._client: Any = None
|
|
45
|
+
self._signer: Any = None
|
|
46
|
+
self._entered = False
|
|
47
|
+
self._closed = False
|
|
48
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
49
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def config(self) -> ClientConfig:
|
|
53
|
+
"""客户端固定绑定的只读配置。"""
|
|
54
|
+
return self._config
|
|
55
|
+
|
|
56
|
+
async def __aenter__(self) -> "AsyncClient":
|
|
57
|
+
if self._entered or self._closed:
|
|
58
|
+
raise ClientClosedError("异步客户端不能重复打开")
|
|
59
|
+
self._entered = True
|
|
60
|
+
self._loop = asyncio.get_running_loop()
|
|
61
|
+
session = self._session or aioboto3.Session()
|
|
62
|
+
options = requests.connection_options(self.config)
|
|
63
|
+
options["config"] = Config(**requests.transport_options(self.config))
|
|
64
|
+
try:
|
|
65
|
+
self._client = await self._stack.enter_async_context(
|
|
66
|
+
session.client("s3", **options)
|
|
67
|
+
)
|
|
68
|
+
self._signer = self._client
|
|
69
|
+
if (
|
|
70
|
+
self.config.presign_endpoint
|
|
71
|
+
and self.config.presign_endpoint != self.config.endpoint
|
|
72
|
+
):
|
|
73
|
+
options["endpoint_url"] = self.config.presign_endpoint
|
|
74
|
+
self._signer = await self._stack.enter_async_context(
|
|
75
|
+
session.client("s3", **options)
|
|
76
|
+
)
|
|
77
|
+
except BaseException:
|
|
78
|
+
await self.aclose()
|
|
79
|
+
raise
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
async def __aexit__(
|
|
83
|
+
self,
|
|
84
|
+
exc_type: type[BaseException] | None,
|
|
85
|
+
exc: BaseException | None,
|
|
86
|
+
traceback: TracebackType | None,
|
|
87
|
+
) -> None:
|
|
88
|
+
await self.aclose()
|
|
89
|
+
|
|
90
|
+
async def aclose(self) -> None:
|
|
91
|
+
"""关闭底层连接;重复取消也等待清理完成,再传播 CancelledError。"""
|
|
92
|
+
if self._loop is not None and self._loop is not asyncio.get_running_loop():
|
|
93
|
+
raise InvalidRequestError("异步客户端不能跨事件循环关闭")
|
|
94
|
+
self._closed = True
|
|
95
|
+
if self._close_task is None:
|
|
96
|
+
self._close_task = asyncio.create_task(self._stack.aclose())
|
|
97
|
+
canceled: asyncio.CancelledError | None = None
|
|
98
|
+
while not self._close_task.done():
|
|
99
|
+
try:
|
|
100
|
+
await asyncio.shield(self._close_task)
|
|
101
|
+
except asyncio.CancelledError as error:
|
|
102
|
+
canceled = error
|
|
103
|
+
except Exception:
|
|
104
|
+
break
|
|
105
|
+
if canceled is not None:
|
|
106
|
+
if not self._close_task.cancelled():
|
|
107
|
+
self._close_task.exception()
|
|
108
|
+
raise canceled
|
|
109
|
+
self._close_task.result()
|
|
110
|
+
|
|
111
|
+
def _ensure_open(self) -> None:
|
|
112
|
+
if self._closed or self._client is None:
|
|
113
|
+
raise ClientClosedError("异步对象存储客户端未打开或已关闭")
|
|
114
|
+
if self._loop is not asyncio.get_running_loop():
|
|
115
|
+
raise InvalidRequestError("异步客户端不能跨事件循环共享")
|
|
116
|
+
|
|
117
|
+
async def _call(self, operation: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
118
|
+
self._ensure_open()
|
|
119
|
+
try:
|
|
120
|
+
return cast(
|
|
121
|
+
dict[str, Any], await getattr(self._client, operation)(**params)
|
|
122
|
+
)
|
|
123
|
+
except (BotoCoreError, ClientError) as error:
|
|
124
|
+
raise provider_error(error) from error
|
|
125
|
+
|
|
126
|
+
async def check(self) -> None:
|
|
127
|
+
"""执行 HeadBucket 检查;不创建 Bucket,也不证明具有全部对象权限。"""
|
|
128
|
+
await self._call("head_bucket", {"Bucket": self.config.bucket})
|
|
129
|
+
|
|
130
|
+
async def stat(self, key: str, *, version_id: str | None = None) -> ObjectInfo:
|
|
131
|
+
"""读取元数据;version_id 省略时查询当前对象。"""
|
|
132
|
+
return object_info(
|
|
133
|
+
key,
|
|
134
|
+
await self._call(
|
|
135
|
+
"head_object", requests.object_request(self.config, key, version_id)
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
async def delete(self, key: str, *, version_id: str | None = None) -> None:
|
|
140
|
+
"""删除对象或指定版本;删除标记行为由 Bucket 版本策略决定。"""
|
|
141
|
+
await self._call(
|
|
142
|
+
"delete_object", requests.object_request(self.config, key, version_id)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
async def upload_bytes(
|
|
146
|
+
self,
|
|
147
|
+
key: str,
|
|
148
|
+
data: bytes,
|
|
149
|
+
*,
|
|
150
|
+
content_type: str | None = None,
|
|
151
|
+
metadata: Mapping[str, str] | None = None,
|
|
152
|
+
checksum_sha256: str | None = None,
|
|
153
|
+
) -> WriteResult:
|
|
154
|
+
"""单次上传最多 5 GiB,直接返回写入 ETag,不额外 Stat。"""
|
|
155
|
+
if not isinstance(data, bytes):
|
|
156
|
+
raise InvalidRequestError("data 必须是 bytes")
|
|
157
|
+
params = requests.upload_request(
|
|
158
|
+
self.config, key, len(data), content_type, metadata, checksum_sha256
|
|
159
|
+
)
|
|
160
|
+
return write_result(await self._call("put_object", {**params, "Body": data}))
|
|
161
|
+
|
|
162
|
+
async def upload_file(
|
|
163
|
+
self,
|
|
164
|
+
key: str,
|
|
165
|
+
source: str | Path,
|
|
166
|
+
*,
|
|
167
|
+
content_type: str | None = None,
|
|
168
|
+
metadata: Mapping[str, str] | None = None,
|
|
169
|
+
checksum_sha256: str | None = None,
|
|
170
|
+
) -> WriteResult:
|
|
171
|
+
"""单次文件上传,不自动分片;上传及重试期间调用方不得修改源文件。"""
|
|
172
|
+
self._ensure_open()
|
|
173
|
+
with Path(source).open("rb") as body:
|
|
174
|
+
params = requests.upload_request(
|
|
175
|
+
self.config,
|
|
176
|
+
key,
|
|
177
|
+
Path(source).stat().st_size,
|
|
178
|
+
content_type,
|
|
179
|
+
metadata,
|
|
180
|
+
checksum_sha256,
|
|
181
|
+
)
|
|
182
|
+
return write_result(
|
|
183
|
+
await self._call("put_object", {**params, "Body": body})
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
@asynccontextmanager
|
|
187
|
+
async def open_object(
|
|
188
|
+
self, key: str, *, version_id: str | None = None
|
|
189
|
+
) -> AsyncIterator[AsyncObjectStream]:
|
|
190
|
+
"""在 async with 中读取对象;异常或提前退出都会关闭响应体,读流失败不重放。"""
|
|
191
|
+
response = await self._call(
|
|
192
|
+
"get_object", requests.object_request(self.config, key, version_id)
|
|
193
|
+
)
|
|
194
|
+
body = response["Body"]
|
|
195
|
+
try:
|
|
196
|
+
stream = AsyncObjectStream(object_info(key, response), body)
|
|
197
|
+
except BaseException:
|
|
198
|
+
body.close()
|
|
199
|
+
raise
|
|
200
|
+
try:
|
|
201
|
+
yield stream
|
|
202
|
+
finally:
|
|
203
|
+
await stream.aclose()
|
|
204
|
+
|
|
205
|
+
async def download_file(
|
|
206
|
+
self, key: str, destination: str | Path, *, version_id: str | None = None
|
|
207
|
+
) -> ObjectInfo:
|
|
208
|
+
"""下载成功后原子替换目标;失败清理临时文件,父目录须由调用方准备。"""
|
|
209
|
+
async with self.open_object(key, version_id=version_id) as stream:
|
|
210
|
+
with atomic_destination(destination) as (output, _):
|
|
211
|
+
async for chunk in stream.iter_chunks():
|
|
212
|
+
await file_io(partial(output.write, chunk))
|
|
213
|
+
return stream.info
|
|
214
|
+
|
|
215
|
+
async def _presign(
|
|
216
|
+
self,
|
|
217
|
+
operation: str,
|
|
218
|
+
method: str,
|
|
219
|
+
params: dict[str, Any],
|
|
220
|
+
expires_in: int | None,
|
|
221
|
+
) -> PresignedRequest:
|
|
222
|
+
self._ensure_open()
|
|
223
|
+
ttl = self.config.ttl(expires_in)
|
|
224
|
+
expires_at = datetime.now(UTC) + timedelta(seconds=ttl)
|
|
225
|
+
try:
|
|
226
|
+
url = await self._signer.generate_presigned_url(
|
|
227
|
+
operation, Params=params, ExpiresIn=ttl, HttpMethod=method
|
|
228
|
+
)
|
|
229
|
+
except (BotoCoreError, ClientError) as error:
|
|
230
|
+
raise provider_error(error) from error
|
|
231
|
+
return PresignedRequest(
|
|
232
|
+
url, method, requests.signed_headers(params), expires_at
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def presign_get(
|
|
236
|
+
self, key: str, *, version_id: str | None = None, expires_in: int | None = None
|
|
237
|
+
) -> PresignedRequest:
|
|
238
|
+
"""签发下载请求;不检查对象存在性,默认有效期 900 秒。"""
|
|
239
|
+
return await self._presign(
|
|
240
|
+
"get_object",
|
|
241
|
+
"GET",
|
|
242
|
+
requests.object_request(self.config, key, version_id),
|
|
243
|
+
expires_in,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
async def presign_put(
|
|
247
|
+
self,
|
|
248
|
+
key: str,
|
|
249
|
+
*,
|
|
250
|
+
size: int,
|
|
251
|
+
content_type: str | None = None,
|
|
252
|
+
metadata: Mapping[str, str] | None = None,
|
|
253
|
+
checksum_sha256: str | None = None,
|
|
254
|
+
expires_in: int | None = None,
|
|
255
|
+
) -> PresignedRequest:
|
|
256
|
+
"""签发单次上传;执行时须保留声明的大小、媒体类型及元数据头。"""
|
|
257
|
+
return await self._presign(
|
|
258
|
+
"put_object",
|
|
259
|
+
"PUT",
|
|
260
|
+
requests.upload_request(
|
|
261
|
+
self.config, key, size, content_type, metadata, checksum_sha256
|
|
262
|
+
),
|
|
263
|
+
expires_in,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
async def create_multipart(
|
|
267
|
+
self,
|
|
268
|
+
key: str,
|
|
269
|
+
*,
|
|
270
|
+
content_type: str | None = None,
|
|
271
|
+
metadata: Mapping[str, str] | None = None,
|
|
272
|
+
) -> MultipartUpload:
|
|
273
|
+
"""创建分片会话;调用方负责保存 upload_id 并完成或中止。"""
|
|
274
|
+
params = {
|
|
275
|
+
**requests.object_request(self.config, key),
|
|
276
|
+
**requests.upload_fields(content_type, metadata),
|
|
277
|
+
}
|
|
278
|
+
return MultipartUpload(
|
|
279
|
+
key, (await self._call("create_multipart_upload", params))["UploadId"]
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
async def presign_part(
|
|
283
|
+
self,
|
|
284
|
+
key: str,
|
|
285
|
+
upload_id: str,
|
|
286
|
+
part_number: int,
|
|
287
|
+
*,
|
|
288
|
+
expires_in: int | None = None,
|
|
289
|
+
) -> PresignedRequest:
|
|
290
|
+
"""为 1~10000 号分片签发 PUT 请求;签名不证明会话存在。"""
|
|
291
|
+
params = {
|
|
292
|
+
**requests.multipart_request(self.config, key, upload_id),
|
|
293
|
+
"PartNumber": requests.part_number(part_number),
|
|
294
|
+
}
|
|
295
|
+
return await self._presign("upload_part", "PUT", params, expires_in)
|
|
296
|
+
|
|
297
|
+
async def complete_multipart(
|
|
298
|
+
self, key: str, upload_id: str, parts: Sequence[CompletedPart]
|
|
299
|
+
) -> WriteResult:
|
|
300
|
+
"""按编号排序后完成上传;拒绝空列表与重复编号,不修改调用方列表。"""
|
|
301
|
+
params = {
|
|
302
|
+
**requests.multipart_request(self.config, key, upload_id),
|
|
303
|
+
"MultipartUpload": requests.completed_parts(parts),
|
|
304
|
+
}
|
|
305
|
+
return write_result(await self._call("complete_multipart_upload", params))
|
|
306
|
+
|
|
307
|
+
async def abort_multipart(self, key: str, upload_id: str) -> None:
|
|
308
|
+
"""中止分片会话;NoSuchUpload 保持 NotFoundError,不伪装成功。"""
|
|
309
|
+
await self._call(
|
|
310
|
+
"abort_multipart_upload",
|
|
311
|
+
requests.multipart_request(self.config, key, upload_id),
|
|
312
|
+
)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""复用连接的 Boto3 同步客户端。"""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
4
|
+
from contextlib import ExitStack, contextmanager
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import boto3
|
|
11
|
+
from botocore.config import Config
|
|
12
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
13
|
+
|
|
14
|
+
from . import _requests as requests
|
|
15
|
+
from ._files import atomic_destination
|
|
16
|
+
from .config import ClientConfig
|
|
17
|
+
from .errors import ClientClosedError, InvalidRequestError, provider_error
|
|
18
|
+
from .multipart import CompletedPart, MultipartUpload
|
|
19
|
+
from .objects import ObjectInfo, ObjectStream, WriteResult, object_info, write_result
|
|
20
|
+
from .presign import PresignedRequest
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Client:
|
|
24
|
+
"""固定绑定 Bucket/Prefix 的同步对象存储客户端。
|
|
25
|
+
|
|
26
|
+
Session 可注入,否则使用标准 AWS 凭据链。客户端拥有其创建的连接,
|
|
27
|
+
调用方使用 with 或 close 关闭;不要跨进程共享或在请求中途并发关闭。
|
|
28
|
+
不创建 Bucket,不执行隐式健康检查,不管理业务上传会话。
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self, config: ClientConfig, *, session: boto3.Session | None = None
|
|
33
|
+
) -> None:
|
|
34
|
+
self._config = config
|
|
35
|
+
self._stack = ExitStack()
|
|
36
|
+
self._closed = False
|
|
37
|
+
session = session or boto3.Session()
|
|
38
|
+
options = requests.connection_options(config)
|
|
39
|
+
options["config"] = Config(**requests.transport_options(config))
|
|
40
|
+
try:
|
|
41
|
+
self._client = session.client("s3", **options)
|
|
42
|
+
self._stack.callback(self._client.close)
|
|
43
|
+
self._signer = self._client
|
|
44
|
+
if config.presign_endpoint and config.presign_endpoint != config.endpoint:
|
|
45
|
+
options["endpoint_url"] = config.presign_endpoint
|
|
46
|
+
self._signer = session.client("s3", **options)
|
|
47
|
+
self._stack.callback(self._signer.close)
|
|
48
|
+
except BaseException:
|
|
49
|
+
self._stack.close()
|
|
50
|
+
raise
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def config(self) -> ClientConfig:
|
|
54
|
+
"""客户端固定绑定的只读配置。"""
|
|
55
|
+
return self._config
|
|
56
|
+
|
|
57
|
+
def __enter__(self) -> "Client":
|
|
58
|
+
self._ensure_open()
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def __exit__(
|
|
62
|
+
self,
|
|
63
|
+
exc_type: type[BaseException] | None,
|
|
64
|
+
exc: BaseException | None,
|
|
65
|
+
traceback: TracebackType | None,
|
|
66
|
+
) -> None:
|
|
67
|
+
self.close()
|
|
68
|
+
|
|
69
|
+
def close(self) -> None:
|
|
70
|
+
"""幂等关闭创建的底层客户端;不关闭业务注入的 Session。"""
|
|
71
|
+
if not self._closed:
|
|
72
|
+
self._closed = True
|
|
73
|
+
self._stack.close()
|
|
74
|
+
|
|
75
|
+
def _ensure_open(self) -> None:
|
|
76
|
+
if self._closed:
|
|
77
|
+
raise ClientClosedError("对象存储客户端已关闭")
|
|
78
|
+
|
|
79
|
+
def _call(self, operation: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
80
|
+
self._ensure_open()
|
|
81
|
+
try:
|
|
82
|
+
return cast(dict[str, Any], getattr(self._client, operation)(**params))
|
|
83
|
+
except (BotoCoreError, ClientError) as error:
|
|
84
|
+
raise provider_error(error) from error
|
|
85
|
+
|
|
86
|
+
def check(self) -> None:
|
|
87
|
+
"""执行 HeadBucket 检查;不创建 Bucket,也不证明具有全部对象权限。"""
|
|
88
|
+
self._call("head_bucket", {"Bucket": self.config.bucket})
|
|
89
|
+
|
|
90
|
+
def stat(self, key: str, *, version_id: str | None = None) -> ObjectInfo:
|
|
91
|
+
"""读取元数据;version_id 省略时查询当前对象。"""
|
|
92
|
+
return object_info(
|
|
93
|
+
key,
|
|
94
|
+
self._call(
|
|
95
|
+
"head_object", requests.object_request(self.config, key, version_id)
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def delete(self, key: str, *, version_id: str | None = None) -> None:
|
|
100
|
+
"""删除对象或指定版本;删除标记行为由 Bucket 版本策略决定。"""
|
|
101
|
+
self._call(
|
|
102
|
+
"delete_object", requests.object_request(self.config, key, version_id)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def upload_bytes(
|
|
106
|
+
self,
|
|
107
|
+
key: str,
|
|
108
|
+
data: bytes,
|
|
109
|
+
*,
|
|
110
|
+
content_type: str | None = None,
|
|
111
|
+
metadata: Mapping[str, str] | None = None,
|
|
112
|
+
checksum_sha256: str | None = None,
|
|
113
|
+
) -> WriteResult:
|
|
114
|
+
"""单次上传最多 5 GiB,直接返回写入 ETag,不额外 Stat。"""
|
|
115
|
+
if not isinstance(data, bytes):
|
|
116
|
+
raise InvalidRequestError("data 必须是 bytes")
|
|
117
|
+
params = requests.upload_request(
|
|
118
|
+
self.config, key, len(data), content_type, metadata, checksum_sha256
|
|
119
|
+
)
|
|
120
|
+
return write_result(self._call("put_object", {**params, "Body": data}))
|
|
121
|
+
|
|
122
|
+
def upload_file(
|
|
123
|
+
self,
|
|
124
|
+
key: str,
|
|
125
|
+
source: str | Path,
|
|
126
|
+
*,
|
|
127
|
+
content_type: str | None = None,
|
|
128
|
+
metadata: Mapping[str, str] | None = None,
|
|
129
|
+
checksum_sha256: str | None = None,
|
|
130
|
+
) -> WriteResult:
|
|
131
|
+
"""单次文件上传,不自动分片;上传及重试期间调用方不得修改源文件。"""
|
|
132
|
+
self._ensure_open()
|
|
133
|
+
with Path(source).open("rb") as body:
|
|
134
|
+
params = requests.upload_request(
|
|
135
|
+
self.config,
|
|
136
|
+
key,
|
|
137
|
+
Path(source).stat().st_size,
|
|
138
|
+
content_type,
|
|
139
|
+
metadata,
|
|
140
|
+
checksum_sha256,
|
|
141
|
+
)
|
|
142
|
+
return write_result(self._call("put_object", {**params, "Body": body}))
|
|
143
|
+
|
|
144
|
+
@contextmanager
|
|
145
|
+
def open_object(
|
|
146
|
+
self, key: str, *, version_id: str | None = None
|
|
147
|
+
) -> Iterator[ObjectStream]:
|
|
148
|
+
"""在 with 中读取对象;异常或提前退出都会关闭响应体,读流失败不重放。"""
|
|
149
|
+
response = self._call(
|
|
150
|
+
"get_object", requests.object_request(self.config, key, version_id)
|
|
151
|
+
)
|
|
152
|
+
body = response["Body"]
|
|
153
|
+
try:
|
|
154
|
+
stream = ObjectStream(object_info(key, response), body)
|
|
155
|
+
except BaseException:
|
|
156
|
+
body.close()
|
|
157
|
+
raise
|
|
158
|
+
try:
|
|
159
|
+
yield stream
|
|
160
|
+
finally:
|
|
161
|
+
stream.close()
|
|
162
|
+
|
|
163
|
+
def download_file(
|
|
164
|
+
self, key: str, destination: str | Path, *, version_id: str | None = None
|
|
165
|
+
) -> ObjectInfo:
|
|
166
|
+
"""下载成功后原子替换目标;失败清理临时文件,父目录须由调用方准备。"""
|
|
167
|
+
with self.open_object(key, version_id=version_id) as stream:
|
|
168
|
+
with atomic_destination(destination) as (output, _):
|
|
169
|
+
for chunk in stream.iter_chunks():
|
|
170
|
+
output.write(chunk)
|
|
171
|
+
return stream.info
|
|
172
|
+
|
|
173
|
+
def _presign(
|
|
174
|
+
self,
|
|
175
|
+
operation: str,
|
|
176
|
+
method: str,
|
|
177
|
+
params: dict[str, Any],
|
|
178
|
+
expires_in: int | None,
|
|
179
|
+
) -> PresignedRequest:
|
|
180
|
+
self._ensure_open()
|
|
181
|
+
ttl = self.config.ttl(expires_in)
|
|
182
|
+
expires_at = datetime.now(UTC) + timedelta(seconds=ttl)
|
|
183
|
+
try:
|
|
184
|
+
url = self._signer.generate_presigned_url(
|
|
185
|
+
operation, Params=params, ExpiresIn=ttl, HttpMethod=method
|
|
186
|
+
)
|
|
187
|
+
except (BotoCoreError, ClientError) as error:
|
|
188
|
+
raise provider_error(error) from error
|
|
189
|
+
return PresignedRequest(
|
|
190
|
+
url, method, requests.signed_headers(params), expires_at
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def presign_get(
|
|
194
|
+
self, key: str, *, version_id: str | None = None, expires_in: int | None = None
|
|
195
|
+
) -> PresignedRequest:
|
|
196
|
+
"""签发下载请求;不检查对象存在性,默认有效期 900 秒。"""
|
|
197
|
+
return self._presign(
|
|
198
|
+
"get_object",
|
|
199
|
+
"GET",
|
|
200
|
+
requests.object_request(self.config, key, version_id),
|
|
201
|
+
expires_in,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def presign_put(
|
|
205
|
+
self,
|
|
206
|
+
key: str,
|
|
207
|
+
*,
|
|
208
|
+
size: int,
|
|
209
|
+
content_type: str | None = None,
|
|
210
|
+
metadata: Mapping[str, str] | None = None,
|
|
211
|
+
checksum_sha256: str | None = None,
|
|
212
|
+
expires_in: int | None = None,
|
|
213
|
+
) -> PresignedRequest:
|
|
214
|
+
"""签发单次上传;执行时须保留声明的大小、媒体类型及元数据头。"""
|
|
215
|
+
return self._presign(
|
|
216
|
+
"put_object",
|
|
217
|
+
"PUT",
|
|
218
|
+
requests.upload_request(
|
|
219
|
+
self.config, key, size, content_type, metadata, checksum_sha256
|
|
220
|
+
),
|
|
221
|
+
expires_in,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def create_multipart(
|
|
225
|
+
self,
|
|
226
|
+
key: str,
|
|
227
|
+
*,
|
|
228
|
+
content_type: str | None = None,
|
|
229
|
+
metadata: Mapping[str, str] | None = None,
|
|
230
|
+
) -> MultipartUpload:
|
|
231
|
+
"""创建分片会话;调用方负责保存 upload_id 并完成或中止。"""
|
|
232
|
+
params = {
|
|
233
|
+
**requests.object_request(self.config, key),
|
|
234
|
+
**requests.upload_fields(content_type, metadata),
|
|
235
|
+
}
|
|
236
|
+
return MultipartUpload(
|
|
237
|
+
key, self._call("create_multipart_upload", params)["UploadId"]
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def presign_part(
|
|
241
|
+
self,
|
|
242
|
+
key: str,
|
|
243
|
+
upload_id: str,
|
|
244
|
+
part_number: int,
|
|
245
|
+
*,
|
|
246
|
+
expires_in: int | None = None,
|
|
247
|
+
) -> PresignedRequest:
|
|
248
|
+
"""为 1~10000 号分片签发 PUT 请求;签名不证明会话存在。"""
|
|
249
|
+
params = {
|
|
250
|
+
**requests.multipart_request(self.config, key, upload_id),
|
|
251
|
+
"PartNumber": requests.part_number(part_number),
|
|
252
|
+
}
|
|
253
|
+
return self._presign("upload_part", "PUT", params, expires_in)
|
|
254
|
+
|
|
255
|
+
def complete_multipart(
|
|
256
|
+
self, key: str, upload_id: str, parts: Sequence[CompletedPart]
|
|
257
|
+
) -> WriteResult:
|
|
258
|
+
"""按编号排序后完成上传;拒绝空列表与重复编号,不修改调用方列表。"""
|
|
259
|
+
params = {
|
|
260
|
+
**requests.multipart_request(self.config, key, upload_id),
|
|
261
|
+
"MultipartUpload": requests.completed_parts(parts),
|
|
262
|
+
}
|
|
263
|
+
return write_result(self._call("complete_multipart_upload", params))
|
|
264
|
+
|
|
265
|
+
def abort_multipart(self, key: str, upload_id: str) -> None:
|
|
266
|
+
"""中止分片会话;NoSuchUpload 保持 NotFoundError,不伪装成功。"""
|
|
267
|
+
self._call(
|
|
268
|
+
"abort_multipart_upload",
|
|
269
|
+
requests.multipart_request(self.config, key, upload_id),
|
|
270
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""单个 Bucket/Prefix 的不可变连接配置。"""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import unicodedata
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
from .errors import InvalidRequestError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def text(value: str, name: str, *, empty: bool = False) -> str:
|
|
12
|
+
if not isinstance(value, str) or (not empty and not value):
|
|
13
|
+
raise InvalidRequestError(f"{name} 必须是非空字符串")
|
|
14
|
+
try:
|
|
15
|
+
value.encode("utf-8")
|
|
16
|
+
except UnicodeError as error:
|
|
17
|
+
raise InvalidRequestError(f"{name} 必须是有效 UTF-8") from error
|
|
18
|
+
if any(unicodedata.category(char) == "Cc" for char in value):
|
|
19
|
+
raise InvalidRequestError(f"{name} 不能包含控制字符")
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, kw_only=True)
|
|
24
|
+
class ClientConfig:
|
|
25
|
+
"""绑定存储位置和传输策略,不持有凭据。
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
bucket: 已由部署创建的 Bucket。
|
|
29
|
+
region: 显式签名 Region。
|
|
30
|
+
prefix: 可选物理前缀;非空时规范为一个结尾斜杠。
|
|
31
|
+
endpoint: 内部 S3 根地址;None 使用 AWS 标准端点。
|
|
32
|
+
presign_endpoint: 面向签名消费者的根地址,省略沿用 endpoint。
|
|
33
|
+
use_path_style: MinIO 常设为 True,默认 False。
|
|
34
|
+
connect_timeout: 连接超时秒数,默认 5。
|
|
35
|
+
read_timeout: 单次读取超时秒数,默认 5,不是整次传输期限。
|
|
36
|
+
max_attempts: standard 模式总尝试次数,包含首次,默认 3。
|
|
37
|
+
default_presign_ttl: 预签名默认秒数,默认 900,范围 60~3600。
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
bucket: str
|
|
41
|
+
region: str
|
|
42
|
+
prefix: str = ""
|
|
43
|
+
endpoint: str | None = None
|
|
44
|
+
presign_endpoint: str | None = None
|
|
45
|
+
use_path_style: bool = False
|
|
46
|
+
connect_timeout: float = 5.0
|
|
47
|
+
read_timeout: float = 5.0
|
|
48
|
+
max_attempts: int = 3
|
|
49
|
+
default_presign_ttl: int = 900
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
for name in ("bucket", "region"):
|
|
53
|
+
value = text(getattr(self, name), name)
|
|
54
|
+
if value != value.strip():
|
|
55
|
+
raise InvalidRequestError(f"{name} 不能包含首尾空白")
|
|
56
|
+
prefix = text(self.prefix, "prefix", empty=True)
|
|
57
|
+
if prefix.startswith("/"):
|
|
58
|
+
raise InvalidRequestError("prefix 不能以 / 开头")
|
|
59
|
+
prefix = prefix.rstrip("/")
|
|
60
|
+
object.__setattr__(self, "prefix", prefix + "/" if prefix else "")
|
|
61
|
+
if len(self.prefix.encode()) > 1024:
|
|
62
|
+
raise InvalidRequestError("prefix 超过对象键字节限制")
|
|
63
|
+
for name in ("endpoint", "presign_endpoint"):
|
|
64
|
+
value = getattr(self, name)
|
|
65
|
+
if value is not None:
|
|
66
|
+
self._validate_endpoint(value)
|
|
67
|
+
if self.presign_endpoint is not None and self.endpoint is None:
|
|
68
|
+
raise InvalidRequestError("配置 presign_endpoint 时必须设置 endpoint")
|
|
69
|
+
if type(self.use_path_style) is not bool:
|
|
70
|
+
raise InvalidRequestError("use_path_style 必须是布尔值")
|
|
71
|
+
for timeout in (self.connect_timeout, self.read_timeout):
|
|
72
|
+
if (
|
|
73
|
+
type(timeout) not in (int, float)
|
|
74
|
+
or not math.isfinite(timeout)
|
|
75
|
+
or timeout <= 0
|
|
76
|
+
):
|
|
77
|
+
raise InvalidRequestError("超时必须是有限正数")
|
|
78
|
+
if type(self.max_attempts) is not int or not 1 <= self.max_attempts <= 10:
|
|
79
|
+
raise InvalidRequestError("max_attempts 必须在 1~10 之间")
|
|
80
|
+
self.ttl(self.default_presign_ttl)
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _validate_endpoint(value: str) -> None:
|
|
84
|
+
text(value, "endpoint")
|
|
85
|
+
try:
|
|
86
|
+
parsed = urlsplit(value)
|
|
87
|
+
_ = parsed.port
|
|
88
|
+
except ValueError as error:
|
|
89
|
+
raise InvalidRequestError("endpoint 非法") from error
|
|
90
|
+
if (
|
|
91
|
+
value != value.strip()
|
|
92
|
+
or parsed.scheme not in {"http", "https"}
|
|
93
|
+
or not parsed.hostname
|
|
94
|
+
or parsed.username is not None
|
|
95
|
+
or parsed.password is not None
|
|
96
|
+
or parsed.path not in {"", "/"}
|
|
97
|
+
or parsed.query
|
|
98
|
+
or parsed.fragment
|
|
99
|
+
):
|
|
100
|
+
raise InvalidRequestError(
|
|
101
|
+
"endpoint 必须是无凭据、路径或查询参数的 HTTP(S) 根地址"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def physical_key(self, key: str) -> str:
|
|
105
|
+
"""组合 Prefix 与逻辑 key,不裁剪或规范化对象键内容。"""
|
|
106
|
+
text(key, "key")
|
|
107
|
+
if key != key.strip() or key.startswith("/"):
|
|
108
|
+
raise InvalidRequestError("key 不能以 / 开头或包含首尾空白")
|
|
109
|
+
result = self.prefix + key
|
|
110
|
+
if len(result.encode()) > 1024:
|
|
111
|
+
raise InvalidRequestError("物理对象键超过 1024 字节")
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
def ttl(self, value: int | None) -> int:
|
|
115
|
+
"""解析预签名秒数;None 使用客户端默认值。"""
|
|
116
|
+
result = self.default_presign_ttl if value is None else value
|
|
117
|
+
if type(result) is not int or not 60 <= result <= 3600:
|
|
118
|
+
raise InvalidRequestError("预签名有效期必须在 60~3600 秒之间")
|
|
119
|
+
return result
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""稳定错误类别;底层诊断仅通过 cause 保留,不拼接凭据或签名 URL。"""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StorageError(Exception):
|
|
9
|
+
"""对象存储失败;__cause__ 保留原始异常,日志不应直接展开敏感诊断。"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidRequestError(StorageError, ValueError):
|
|
13
|
+
"""配置、逻辑 key 或操作参数非法。"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NotFoundError(StorageError):
|
|
17
|
+
"""对象、版本、Bucket 或分片会话不存在。"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ForbiddenError(StorageError):
|
|
21
|
+
"""凭据无效或存储端拒绝访问。"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConflictError(StorageError):
|
|
25
|
+
"""存储端报告操作冲突。"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PreconditionFailedError(StorageError):
|
|
29
|
+
"""存储端拒绝条件请求。"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UnavailableError(StorageError):
|
|
33
|
+
"""连接、超时、服务或无法归类的存储故障。"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ClientClosedError(StorageError):
|
|
37
|
+
"""客户端未打开或已关闭。"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def provider_error(error: BotoCoreError | ClientError) -> StorageError:
|
|
41
|
+
kind: type[StorageError] = UnavailableError
|
|
42
|
+
if isinstance(error, ClientError):
|
|
43
|
+
response: dict[str, Any] = error.response
|
|
44
|
+
code = str(response.get("Error", {}).get("Code", ""))
|
|
45
|
+
status = response.get("ResponseMetadata", {}).get("HTTPStatusCode")
|
|
46
|
+
if (
|
|
47
|
+
code
|
|
48
|
+
in {
|
|
49
|
+
"NoSuchKey",
|
|
50
|
+
"NoSuchBucket",
|
|
51
|
+
"NoSuchVersion",
|
|
52
|
+
"NoSuchUpload",
|
|
53
|
+
"NotFound",
|
|
54
|
+
}
|
|
55
|
+
or status == 404
|
|
56
|
+
):
|
|
57
|
+
kind = NotFoundError
|
|
58
|
+
elif code in {
|
|
59
|
+
"AccessDenied",
|
|
60
|
+
"InvalidAccessKeyId",
|
|
61
|
+
"SignatureDoesNotMatch",
|
|
62
|
+
"ExpiredToken",
|
|
63
|
+
} or status in {401, 403}:
|
|
64
|
+
kind = ForbiddenError
|
|
65
|
+
elif code == "PreconditionFailed" or status == 412:
|
|
66
|
+
kind = PreconditionFailedError
|
|
67
|
+
elif code == "ConditionalRequestConflict" or status == 409:
|
|
68
|
+
kind = ConflictError
|
|
69
|
+
elif status == 400:
|
|
70
|
+
kind = InvalidRequestError
|
|
71
|
+
return kind("对象存储请求失败")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""显式分片上传标识与完成参数,业务方负责会话保存和放弃清理。"""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class MultipartUpload:
|
|
8
|
+
"""创建结果;key 为逻辑键,upload_id 原样交回后续操作。"""
|
|
9
|
+
|
|
10
|
+
key: str
|
|
11
|
+
upload_id: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class CompletedPart:
|
|
16
|
+
"""上传成功的分片编号和存储端 ETag;不能用本地摘要替代 ETag。"""
|
|
17
|
+
|
|
18
|
+
part_number: int
|
|
19
|
+
etag: str
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""对象操作结果及由上下文持有的读取流。"""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Iterator, Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
9
|
+
|
|
10
|
+
from .errors import ClientClosedError, InvalidRequestError, provider_error
|
|
11
|
+
|
|
12
|
+
CHUNK_SIZE = 1024 * 1024
|
|
13
|
+
MAX_SINGLE_PUT_BYTES = 5 * 1024**3
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class WriteResult:
|
|
18
|
+
"""存储端写入结果;ETag 为不透明值,可能缺失,不代表内容 MD5。"""
|
|
19
|
+
|
|
20
|
+
etag: str | None
|
|
21
|
+
version_id: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ObjectInfo:
|
|
26
|
+
"""逻辑对象元数据;key 不包含客户端绑定的物理 Prefix。"""
|
|
27
|
+
|
|
28
|
+
key: str
|
|
29
|
+
size: int
|
|
30
|
+
etag: str | None = None
|
|
31
|
+
version_id: str | None = None
|
|
32
|
+
content_type: str | None = None
|
|
33
|
+
last_modified: datetime | None = None
|
|
34
|
+
metadata: Mapping[str, str] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def object_info(key: str, response: dict[str, Any]) -> ObjectInfo:
|
|
38
|
+
return ObjectInfo(
|
|
39
|
+
key=key,
|
|
40
|
+
size=response["ContentLength"],
|
|
41
|
+
etag=response.get("ETag"),
|
|
42
|
+
version_id=response.get("VersionId"),
|
|
43
|
+
content_type=response.get("ContentType"),
|
|
44
|
+
last_modified=response.get("LastModified"),
|
|
45
|
+
metadata=dict(response.get("Metadata", {})),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def write_result(response: dict[str, Any]) -> WriteResult:
|
|
50
|
+
return WriteResult(response.get("ETag"), response.get("VersionId"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ObjectStream:
|
|
54
|
+
"""仅在 open_object 上下文内使用的同步流;退出时关闭底层响应。"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, info: ObjectInfo, body: Any) -> None:
|
|
57
|
+
self.info = info
|
|
58
|
+
self._body = body
|
|
59
|
+
self._closed = False
|
|
60
|
+
|
|
61
|
+
def read(self, size: int = -1) -> bytes:
|
|
62
|
+
"""读取至多 size 字节;-1 读取剩余全部,调用方承担内存预算。"""
|
|
63
|
+
if self._closed:
|
|
64
|
+
raise ClientClosedError("对象读取流已关闭")
|
|
65
|
+
if type(size) is not int or size < -1:
|
|
66
|
+
raise InvalidRequestError("读取大小必须是 -1 或非负整数")
|
|
67
|
+
try:
|
|
68
|
+
return cast(bytes, self._body.read(None if size == -1 else size))
|
|
69
|
+
except (BotoCoreError, ClientError) as error:
|
|
70
|
+
raise provider_error(error) from error
|
|
71
|
+
|
|
72
|
+
def iter_chunks(self, chunk_size: int = CHUNK_SIZE) -> Iterator[bytes]:
|
|
73
|
+
"""按消费进度读取,不预先缓存整个对象。"""
|
|
74
|
+
if type(chunk_size) is not int or chunk_size <= 0:
|
|
75
|
+
raise InvalidRequestError("分块大小必须是正整数")
|
|
76
|
+
while chunk := self.read(chunk_size):
|
|
77
|
+
yield chunk
|
|
78
|
+
|
|
79
|
+
def close(self) -> None:
|
|
80
|
+
"""幂等关闭响应体;通常由 open_object 上下文自动执行。"""
|
|
81
|
+
if not self._closed:
|
|
82
|
+
self._closed = True
|
|
83
|
+
self._body.close()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AsyncObjectStream:
|
|
87
|
+
"""仅在 open_object 异步上下文内使用的流,不跨事件循环共享。"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, info: ObjectInfo, body: Any) -> None:
|
|
90
|
+
self.info = info
|
|
91
|
+
self._body = body
|
|
92
|
+
self._closed = False
|
|
93
|
+
|
|
94
|
+
async def read(self, size: int = -1) -> bytes:
|
|
95
|
+
"""异步读取;取消保持 CancelledError,不重启已经开始的响应流。"""
|
|
96
|
+
if self._closed:
|
|
97
|
+
raise ClientClosedError("对象读取流已关闭")
|
|
98
|
+
if type(size) is not int or size < -1:
|
|
99
|
+
raise InvalidRequestError("读取大小必须是 -1 或非负整数")
|
|
100
|
+
try:
|
|
101
|
+
return cast(bytes, await self._body.read(None if size == -1 else size))
|
|
102
|
+
except (BotoCoreError, ClientError) as error:
|
|
103
|
+
raise provider_error(error) from error
|
|
104
|
+
|
|
105
|
+
async def iter_chunks(self, chunk_size: int = CHUNK_SIZE) -> AsyncIterator[bytes]:
|
|
106
|
+
"""按需读取有界分块,消费者控制下一次读取。"""
|
|
107
|
+
if type(chunk_size) is not int or chunk_size <= 0:
|
|
108
|
+
raise InvalidRequestError("分块大小必须是正整数")
|
|
109
|
+
while chunk := await self.read(chunk_size):
|
|
110
|
+
yield chunk
|
|
111
|
+
|
|
112
|
+
async def aclose(self) -> None:
|
|
113
|
+
"""幂等关闭底层 HTTP 响应;不消费剩余内容。"""
|
|
114
|
+
if not self._closed:
|
|
115
|
+
self._closed = True
|
|
116
|
+
self._body.close()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""完整预签名请求描述,不包含长期认证凭据。"""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, repr=False)
|
|
9
|
+
class PresignedRequest:
|
|
10
|
+
"""临时访问能力;URL 不可改写,method 与 headers 必须原样执行。
|
|
11
|
+
|
|
12
|
+
签名可能因临时凭据先到期而提前失效。不要在日志中输出 URL。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
url: str
|
|
16
|
+
method: str
|
|
17
|
+
headers: Mapping[str, str]
|
|
18
|
+
expires_at: datetime
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stellarmesh-objectstorage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 基于 Boto3 与 aioboto3 的进程内对象存储客户端
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/L1ndenbaum/stellarmesh-sdk
|
|
7
|
+
Project-URL: Documentation, https://github.com/L1ndenbaum/stellarmesh-sdk/blob/dev/docs/sdk/python/objectstorage.md
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: aioboto3==15.5.0
|
|
12
|
+
Requires-Dist: boto3<1.40.62,>=1.40.46
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# Stellarmesh Python 对象存储 SDK
|
|
16
|
+
|
|
17
|
+
直接访问 AWS S3/MinIO 的小型进程内客户端,同步使用 Boto3,异步使用 aioboto3。
|
|
18
|
+
不需要 Storage 服务。要求 Python 3.11+,Bucket 和项目凭据由业务部署准备。
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pip install stellarmesh-objectstorage==0.1.0
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from stellarmesh_objectstorage import Client, ClientConfig, StorageError
|
|
26
|
+
|
|
27
|
+
config = ClientConfig(bucket="example-documents", region="us-east-1")
|
|
28
|
+
try:
|
|
29
|
+
with Client(config) as storage:
|
|
30
|
+
result = storage.upload_bytes("hello.txt", b"hello", content_type="text/plain")
|
|
31
|
+
print(result.etag)
|
|
32
|
+
except StorageError:
|
|
33
|
+
raise
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
凭据默认使用标准 AWS 凭据链,也可以注入 Session;不要将项目长期凭据交给浏览器。
|
|
37
|
+
异步客户端使用 `async with AsyncClient(config) as storage`,对象流也必须使用上下文关闭。
|
|
38
|
+
单次上传限制 5 GiB,较大对象使用显式 Multipart;SDK 不管理业务上传会话或自动创建 Bucket。
|
|
39
|
+
写入超时或取消不代表服务端没有完成;ETag 是不透明值,不保证为 MD5。
|
|
40
|
+
|
|
41
|
+
完整接入、生命周期与验证边界见[中文指南](https://github.com/L1ndenbaum/stellarmesh-sdk/blob/dev/docs/sdk/python/objectstorage.md)。
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
stellarmesh_objectstorage/__init__.py,sha256=oRtwQhKuvBUvakpuH8njYKnrCSYBEWq-tyMrV3Ta67o,981
|
|
2
|
+
stellarmesh_objectstorage/_files.py,sha256=CLOD0HaNHhNE14W-pF8O-doafdD7az5OiQ-zdVKfgWw,1478
|
|
3
|
+
stellarmesh_objectstorage/_requests.py,sha256=a4YXVqWTdr-OYBAqOC_Q2ltM_HNzAbRlB3NGQBvFeT4,5269
|
|
4
|
+
stellarmesh_objectstorage/async_client.py,sha256=p-7Fp0Fqpkp8Vt42EkVNbpql_04YjGgJi6GktY9_3jo,11799
|
|
5
|
+
stellarmesh_objectstorage/client.py,sha256=c_eb9ydlSjryMxy0nmRjuM2bynvQhy0aMnIw2b37ESU,9947
|
|
6
|
+
stellarmesh_objectstorage/config.py,sha256=IC6EUBb5dEqYoFZzfnxaoCSiOjoI9fpvsTv3WVdKB8M,5021
|
|
7
|
+
stellarmesh_objectstorage/errors.py,sha256=BiKEIFhw0kSjtATolsypc3AU1cgyn_rPQoKpPAuvE1k,2143
|
|
8
|
+
stellarmesh_objectstorage/multipart.py,sha256=8Y-IJxzaIHW3-lGfS1jGr3dOew80F1Rl0BzhBTekLEU,469
|
|
9
|
+
stellarmesh_objectstorage/objects.py,sha256=nTFid8HkLhNYUuKGtYuigsjjKyIG-LNhKAOr0brOawo,4261
|
|
10
|
+
stellarmesh_objectstorage/presign.py,sha256=6lS73IdWq3p8Wn9zvOppRcO8hM-L6aXGbFgOV9QWc_8,503
|
|
11
|
+
stellarmesh_objectstorage/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
stellarmesh_objectstorage-0.1.0.dist-info/licenses/LICENSE,sha256=p7EA72H4dJWApvo8NRCLcegqYr9BjEc9-rU3whYXOI0,1067
|
|
13
|
+
stellarmesh_objectstorage-0.1.0.dist-info/METADATA,sha256=56D1jkulbUp0_tjVXTQRJrHPxBiqexN5MNRgaFgd2DY,1766
|
|
14
|
+
stellarmesh_objectstorage-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
15
|
+
stellarmesh_objectstorage-0.1.0.dist-info/top_level.txt,sha256=dE2YhdFUFQMQaBF-rOyxoU_1bCkSlNc8GZadJfaAD_E,26
|
|
16
|
+
stellarmesh_objectstorage-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 L1ndenbaum
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
stellarmesh_objectstorage
|