stellarmesh-objectstorage 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. stellarmesh_objectstorage-0.1.0/LICENSE +21 -0
  2. stellarmesh_objectstorage-0.1.0/PKG-INFO +41 -0
  3. stellarmesh_objectstorage-0.1.0/README.md +27 -0
  4. stellarmesh_objectstorage-0.1.0/pyproject.toml +42 -0
  5. stellarmesh_objectstorage-0.1.0/setup.cfg +4 -0
  6. stellarmesh_objectstorage-0.1.0/src/stellarmesh_objectstorage/__init__.py +39 -0
  7. stellarmesh_objectstorage-0.1.0/src/stellarmesh_objectstorage/_files.py +44 -0
  8. stellarmesh_objectstorage-0.1.0/src/stellarmesh_objectstorage/_requests.py +145 -0
  9. stellarmesh_objectstorage-0.1.0/src/stellarmesh_objectstorage/async_client.py +312 -0
  10. stellarmesh_objectstorage-0.1.0/src/stellarmesh_objectstorage/client.py +270 -0
  11. stellarmesh_objectstorage-0.1.0/src/stellarmesh_objectstorage/config.py +119 -0
  12. stellarmesh_objectstorage-0.1.0/src/stellarmesh_objectstorage/errors.py +71 -0
  13. stellarmesh_objectstorage-0.1.0/src/stellarmesh_objectstorage/multipart.py +19 -0
  14. stellarmesh_objectstorage-0.1.0/src/stellarmesh_objectstorage/objects.py +116 -0
  15. stellarmesh_objectstorage-0.1.0/src/stellarmesh_objectstorage/presign.py +18 -0
  16. stellarmesh_objectstorage-0.1.0/src/stellarmesh_objectstorage/py.typed +0 -0
  17. stellarmesh_objectstorage-0.1.0/src/stellarmesh_objectstorage.egg-info/PKG-INFO +41 -0
  18. stellarmesh_objectstorage-0.1.0/src/stellarmesh_objectstorage.egg-info/SOURCES.txt +23 -0
  19. stellarmesh_objectstorage-0.1.0/src/stellarmesh_objectstorage.egg-info/dependency_links.txt +1 -0
  20. stellarmesh_objectstorage-0.1.0/src/stellarmesh_objectstorage.egg-info/requires.txt +2 -0
  21. stellarmesh_objectstorage-0.1.0/src/stellarmesh_objectstorage.egg-info/top_level.txt +1 -0
  22. stellarmesh_objectstorage-0.1.0/tests/test_async_client.py +134 -0
  23. stellarmesh_objectstorage-0.1.0/tests/test_client.py +231 -0
  24. stellarmesh_objectstorage-0.1.0/tests/test_config.py +38 -0
  25. stellarmesh_objectstorage-0.1.0/tests/test_integration.py +217 -0
@@ -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,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,27 @@
1
+ # Stellarmesh Python 对象存储 SDK
2
+
3
+ 直接访问 AWS S3/MinIO 的小型进程内客户端,同步使用 Boto3,异步使用 aioboto3。
4
+ 不需要 Storage 服务。要求 Python 3.11+,Bucket 和项目凭据由业务部署准备。
5
+
6
+ ```sh
7
+ pip install stellarmesh-objectstorage==0.1.0
8
+ ```
9
+
10
+ ```python
11
+ from stellarmesh_objectstorage import Client, ClientConfig, StorageError
12
+
13
+ config = ClientConfig(bucket="example-documents", region="us-east-1")
14
+ try:
15
+ with Client(config) as storage:
16
+ result = storage.upload_bytes("hello.txt", b"hello", content_type="text/plain")
17
+ print(result.etag)
18
+ except StorageError:
19
+ raise
20
+ ```
21
+
22
+ 凭据默认使用标准 AWS 凭据链,也可以注入 Session;不要将项目长期凭据交给浏览器。
23
+ 异步客户端使用 `async with AsyncClient(config) as storage`,对象流也必须使用上下文关闭。
24
+ 单次上传限制 5 GiB,较大对象使用显式 Multipart;SDK 不管理业务上传会话或自动创建 Bucket。
25
+ 写入超时或取消不代表服务端没有完成;ETag 是不透明值,不保证为 MD5。
26
+
27
+ 完整接入、生命周期与验证边界见[中文指南](https://github.com/L1ndenbaum/stellarmesh-sdk/blob/dev/docs/sdk/python/objectstorage.md)。
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "stellarmesh-objectstorage"
7
+ version = "0.1.0"
8
+ description = "基于 Boto3 与 aioboto3 的进程内对象存储客户端"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = ["aioboto3==15.5.0", "boto3>=1.40.46,<1.40.62"]
14
+
15
+ [project.urls]
16
+ Repository = "https://github.com/L1ndenbaum/stellarmesh-sdk"
17
+ Documentation = "https://github.com/L1ndenbaum/stellarmesh-sdk/blob/dev/docs/sdk/python/objectstorage.md"
18
+
19
+ [dependency-groups]
20
+ dev = ["build>=1,<2", "mypy>=1.10,<2", "pytest>=8,<9", "pytest-asyncio>=0.23,<2", "ruff>=0.6,<1", "twine>=6,<7", "httpx>=0.27,<1"]
21
+
22
+ [tool.setuptools.package-data]
23
+ stellarmesh_objectstorage = ["py.typed"]
24
+
25
+ [tool.ruff]
26
+ target-version = "py311"
27
+
28
+ [tool.ruff.lint]
29
+ select = ["E", "F", "I", "UP", "B", "SIM"]
30
+
31
+ [tool.mypy]
32
+ python_version = "3.11"
33
+ strict = true
34
+
35
+ [[tool.mypy.overrides]]
36
+ module = ["boto3.*", "botocore.*", "aioboto3.*", "aiobotocore.*"]
37
+ ignore_missing_imports = true
38
+
39
+ [tool.pytest.ini_options]
40
+ addopts = "-q"
41
+ asyncio_mode = "strict"
42
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )