stellarmesh-storage 0.1.1__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.
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: stellarmesh-storage
3
+ Version: 0.1.1
4
+ Summary: Stellarmesh 对象存储控制面与预签名数据面客户端
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: httpx<1,>=0.27
8
+ Requires-Dist: pydantic<3,>=2.7
9
+
10
+ # stellarmesh-storage
11
+
12
+ `stellarmesh-storage` 提供严格类型的同步与异步 Python 客户端,通过项目级 `storage-service` 获取预签名请求,并让对象字节直接在客户端与 S3 或 MinIO 之间传输。
13
+
14
+ ```python
15
+ from stellarmesh_storage import Client, ClientConfig
16
+
17
+ config = ClientConfig(
18
+ base_url="http://storage-service:8090",
19
+ token="storage-project-service-token-00000001",
20
+ timeout_seconds=5.0,
21
+ max_attempts=3,
22
+ )
23
+
24
+ with Client(config) as client:
25
+ client.upload_file("documents", "reports/a.pdf", "/work/a.pdf")
26
+ client.download_file("documents", "reports/a.pdf", "/work/result.pdf")
27
+ ```
28
+
29
+ 包提供 Stat、Delete、预签名 GET/PUT、显式 Multipart、单次文件上传和原子文件下载。控制面 service token 不会进入数据面请求,CreateMultipart 与 CompleteMultipart 不会因不确定结果自动重试。
30
+
31
+ 完整接入、重试和安全边界见仓库文档 `docs/sdk/python/storage.md`。
@@ -0,0 +1,22 @@
1
+ # stellarmesh-storage
2
+
3
+ `stellarmesh-storage` 提供严格类型的同步与异步 Python 客户端,通过项目级 `storage-service` 获取预签名请求,并让对象字节直接在客户端与 S3 或 MinIO 之间传输。
4
+
5
+ ```python
6
+ from stellarmesh_storage import Client, ClientConfig
7
+
8
+ config = ClientConfig(
9
+ base_url="http://storage-service:8090",
10
+ token="storage-project-service-token-00000001",
11
+ timeout_seconds=5.0,
12
+ max_attempts=3,
13
+ )
14
+
15
+ with Client(config) as client:
16
+ client.upload_file("documents", "reports/a.pdf", "/work/a.pdf")
17
+ client.download_file("documents", "reports/a.pdf", "/work/result.pdf")
18
+ ```
19
+
20
+ 包提供 Stat、Delete、预签名 GET/PUT、显式 Multipart、单次文件上传和原子文件下载。控制面 service token 不会进入数据面请求,CreateMultipart 与 CompleteMultipart 不会因不确定结果自动重试。
21
+
22
+ 完整接入、重试和安全边界见仓库文档 `docs/sdk/python/storage.md`。
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "stellarmesh-storage"
7
+ version = "0.1.1"
8
+ description = "Stellarmesh 对象存储控制面与预签名数据面客户端"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "httpx>=0.27,<1",
13
+ "pydantic>=2.7,<3",
14
+ ]
15
+
16
+ [dependency-groups]
17
+ dev = [
18
+ "build>=1,<2",
19
+ "jsonschema>=4,<5",
20
+ "mypy>=1.10,<2",
21
+ "openapi-spec-validator>=0.7,<1",
22
+ "PyYAML>=6,<7",
23
+ "pytest>=8,<9",
24
+ "pytest-asyncio>=0.23,<2",
25
+ "ruff>=0.6,<1",
26
+ "types-jsonschema>=4,<5",
27
+ "types-PyYAML>=6,<7",
28
+ "twine>=6,<7",
29
+ ]
30
+
31
+ [tool.setuptools.package-data]
32
+ stellarmesh_storage = ["py.typed"]
33
+
34
+ [tool.ruff]
35
+ line-length = 88
36
+ target-version = "py311"
37
+
38
+ [tool.ruff.lint]
39
+ select = ["E", "F", "I", "UP", "B", "SIM"]
40
+
41
+ [tool.mypy]
42
+ python_version = "3.11"
43
+ strict = true
44
+ packages = ["stellarmesh_storage"]
45
+
46
+ [tool.pytest.ini_options]
47
+ addopts = "-q"
48
+ asyncio_mode = "strict"
49
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,45 @@
1
+ """Stellarmesh 对象存储客户端公共入口。"""
2
+
3
+ from .async_client import AsyncClient
4
+ from .client import Client
5
+ from .errors import (
6
+ ClientClosedError,
7
+ ConflictError,
8
+ ForbiddenError,
9
+ InvalidRequestError,
10
+ NotFoundError,
11
+ PayloadTooLargeError,
12
+ PreconditionFailedError,
13
+ StorageError,
14
+ UnauthorizedError,
15
+ UnavailableError,
16
+ )
17
+ from .models import (
18
+ Checksum,
19
+ ClientConfig,
20
+ CompletedPart,
21
+ MultipartUpload,
22
+ ObjectInfo,
23
+ PresignedRequest,
24
+ )
25
+
26
+ __all__ = [
27
+ "AsyncClient",
28
+ "Checksum",
29
+ "Client",
30
+ "ClientClosedError",
31
+ "ClientConfig",
32
+ "CompletedPart",
33
+ "ConflictError",
34
+ "ForbiddenError",
35
+ "InvalidRequestError",
36
+ "MultipartUpload",
37
+ "NotFoundError",
38
+ "ObjectInfo",
39
+ "PayloadTooLargeError",
40
+ "PreconditionFailedError",
41
+ "PresignedRequest",
42
+ "StorageError",
43
+ "UnauthorizedError",
44
+ "UnavailableError",
45
+ ]
@@ -0,0 +1,105 @@
1
+ """同步和异步客户端共用的响应、重试与签名头处理。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TypeVar
7
+
8
+ import httpx
9
+ from pydantic import ValidationError
10
+
11
+ from .constants import (
12
+ MAX_CONTROL_BODY_BYTES,
13
+ RETRYABLE_STATUS_CODES,
14
+ SERVICE_TOKEN_HEADER,
15
+ )
16
+ from .errors import (
17
+ ConflictError,
18
+ ForbiddenError,
19
+ InvalidRequestError,
20
+ NotFoundError,
21
+ PayloadTooLargeError,
22
+ PreconditionFailedError,
23
+ StorageError,
24
+ UnauthorizedError,
25
+ UnavailableError,
26
+ )
27
+ from .models import ApiEnvelope, StrictModel
28
+
29
+ ModelType = TypeVar("ModelType", bound=StrictModel)
30
+
31
+
32
+ def retry_delay(attempt: int, initial: float, maximum: float) -> float:
33
+ """返回有上限的确定性指数退避。"""
34
+ return float(min(initial * (2 ** (attempt - 1)), maximum))
35
+
36
+
37
+ def retryable_status(status_code: int) -> bool:
38
+ """判断 HTTP 状态是否允许安全操作重试。"""
39
+ return status_code in RETRYABLE_STATUS_CODES
40
+
41
+
42
+ def parse_success(response: httpx.Response, model: type[ModelType]) -> ModelType:
43
+ """严格解析成功 envelope,不在异常中包含原始响应。"""
44
+ try:
45
+ envelope = ApiEnvelope[object].model_validate_json(response.content)
46
+ parsed = model.model_validate_json(json.dumps(envelope.data))
47
+ except (json.JSONDecodeError, ValidationError, ValueError) as error:
48
+ raise UnavailableError(
49
+ "storage service returned an invalid response",
50
+ status_code=response.status_code,
51
+ ) from error
52
+ if envelope.code != response.status_code:
53
+ raise UnavailableError(
54
+ "storage service returned an inconsistent response",
55
+ status_code=response.status_code,
56
+ )
57
+ return parsed
58
+
59
+
60
+ def ensure_success(response: httpx.Response) -> None:
61
+ """校验不需要响应 data 的成功 envelope。"""
62
+ try:
63
+ envelope = ApiEnvelope[dict[str, object]].model_validate_json(response.content)
64
+ except (json.JSONDecodeError, ValidationError, ValueError) as error:
65
+ raise UnavailableError(
66
+ "storage service returned an invalid response",
67
+ status_code=response.status_code,
68
+ ) from error
69
+ if envelope.code != response.status_code:
70
+ raise UnavailableError(
71
+ "storage service returned an inconsistent response",
72
+ status_code=response.status_code,
73
+ )
74
+
75
+
76
+ def response_error(status_code: int) -> StorageError:
77
+ """将服务或数据面状态转换为不含敏感信息的稳定异常。"""
78
+ mapping: dict[int, type[StorageError]] = {
79
+ 400: InvalidRequestError,
80
+ 401: UnauthorizedError,
81
+ 403: ForbiddenError,
82
+ 404: NotFoundError,
83
+ 409: ConflictError,
84
+ 412: PreconditionFailedError,
85
+ 413: PayloadTooLargeError,
86
+ }
87
+ error_type = mapping.get(status_code, UnavailableError)
88
+ return error_type("storage request failed", status_code=status_code)
89
+
90
+
91
+ def signed_headers(headers: dict[str, list[str]]) -> list[tuple[str, str]]:
92
+ """保留服务返回的全部 signed header 值。"""
93
+ if any(name.lower() == SERVICE_TOKEN_HEADER.lower() for name in headers):
94
+ raise UnavailableError("storage service returned a forbidden signed header")
95
+ return [(name, value) for name, values in headers.items() for value in values]
96
+
97
+
98
+ def encode_control_payload(payload: dict[str, object]) -> bytes:
99
+ """生成有界 UTF-8 JSON 控制面请求。"""
100
+ encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode(
101
+ "utf-8"
102
+ )
103
+ if len(encoded) > MAX_CONTROL_BODY_BYTES:
104
+ raise PayloadTooLargeError("storage control request exceeds 64 KiB")
105
+ return encoded
@@ -0,0 +1,183 @@
1
+ """同步与异步客户端共享的 Storage v1 控制面操作描述。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Generic, TypeVar
7
+
8
+ from .models import (
9
+ Checksum,
10
+ CompletedPart,
11
+ MultipartAbortRequest,
12
+ MultipartCompleteRequest,
13
+ MultipartCreateRequest,
14
+ MultipartPartRequest,
15
+ MultipartUpload,
16
+ ObjectInfo,
17
+ ObjectRequest,
18
+ PresignedRequest,
19
+ PresignGetRequest,
20
+ PresignPutRequest,
21
+ StrictModel,
22
+ )
23
+
24
+ ResponseType = TypeVar("ResponseType", bound=StrictModel)
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class ModelOperation(Generic[ResponseType]):
29
+ """描述返回严格模型的控制面操作。"""
30
+
31
+ path: str
32
+ request: StrictModel
33
+ response_model: type[ResponseType]
34
+ retry: bool
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class EmptyOperation:
39
+ """描述只校验成功 envelope 的控制面操作。"""
40
+
41
+ path: str
42
+ request: StrictModel
43
+ retry: bool
44
+
45
+
46
+ def stat(
47
+ namespace: str, key: str, *, version_id: str | None
48
+ ) -> ModelOperation[ObjectInfo]:
49
+ return ModelOperation(
50
+ path="/v1/objects/stat",
51
+ request=ObjectRequest(namespace=namespace, key=key, version_id=version_id),
52
+ response_model=ObjectInfo,
53
+ retry=True,
54
+ )
55
+
56
+
57
+ def delete(namespace: str, key: str, *, version_id: str | None) -> EmptyOperation:
58
+ return EmptyOperation(
59
+ path="/v1/objects/delete",
60
+ request=ObjectRequest(namespace=namespace, key=key, version_id=version_id),
61
+ retry=True,
62
+ )
63
+
64
+
65
+ def presign_get(
66
+ namespace: str,
67
+ key: str,
68
+ *,
69
+ version_id: str | None,
70
+ expires_in: int,
71
+ ) -> ModelOperation[PresignedRequest]:
72
+ return ModelOperation(
73
+ path="/v1/presign/get",
74
+ request=PresignGetRequest(
75
+ namespace=namespace,
76
+ key=key,
77
+ version_id=version_id,
78
+ expires_in=expires_in,
79
+ ),
80
+ response_model=PresignedRequest,
81
+ retry=True,
82
+ )
83
+
84
+
85
+ def presign_put(
86
+ namespace: str,
87
+ key: str,
88
+ *,
89
+ size: int,
90
+ content_type: str | None,
91
+ metadata: dict[str, str] | None,
92
+ checksum: Checksum | None,
93
+ expires_in: int,
94
+ ) -> ModelOperation[PresignedRequest]:
95
+ return ModelOperation(
96
+ path="/v1/presign/put",
97
+ request=PresignPutRequest(
98
+ namespace=namespace,
99
+ key=key,
100
+ size=size,
101
+ content_type=content_type,
102
+ metadata=metadata or {},
103
+ checksum=checksum,
104
+ expires_in=expires_in,
105
+ ),
106
+ response_model=PresignedRequest,
107
+ retry=True,
108
+ )
109
+
110
+
111
+ def create_multipart(
112
+ namespace: str,
113
+ key: str,
114
+ *,
115
+ content_type: str | None,
116
+ metadata: dict[str, str] | None,
117
+ checksum: Checksum | None,
118
+ ) -> ModelOperation[MultipartUpload]:
119
+ return ModelOperation(
120
+ path="/v1/multipart/create",
121
+ request=MultipartCreateRequest(
122
+ namespace=namespace,
123
+ key=key,
124
+ content_type=content_type,
125
+ metadata=metadata or {},
126
+ checksum=checksum,
127
+ ),
128
+ response_model=MultipartUpload,
129
+ retry=False,
130
+ )
131
+
132
+
133
+ def presign_part(
134
+ namespace: str,
135
+ key: str,
136
+ upload_id: str,
137
+ part_number: int,
138
+ *,
139
+ expires_in: int,
140
+ ) -> ModelOperation[PresignedRequest]:
141
+ return ModelOperation(
142
+ path="/v1/multipart/presign-part",
143
+ request=MultipartPartRequest(
144
+ namespace=namespace,
145
+ key=key,
146
+ upload_id=upload_id,
147
+ part_number=part_number,
148
+ expires_in=expires_in,
149
+ ),
150
+ response_model=PresignedRequest,
151
+ retry=True,
152
+ )
153
+
154
+
155
+ def complete_multipart(
156
+ namespace: str,
157
+ key: str,
158
+ upload_id: str,
159
+ parts: list[CompletedPart],
160
+ ) -> ModelOperation[ObjectInfo]:
161
+ return ModelOperation(
162
+ path="/v1/multipart/complete",
163
+ request=MultipartCompleteRequest(
164
+ namespace=namespace,
165
+ key=key,
166
+ upload_id=upload_id,
167
+ parts=parts,
168
+ ),
169
+ response_model=ObjectInfo,
170
+ retry=False,
171
+ )
172
+
173
+
174
+ def abort_multipart(namespace: str, key: str, upload_id: str) -> EmptyOperation:
175
+ return EmptyOperation(
176
+ path="/v1/multipart/abort",
177
+ request=MultipartAbortRequest(
178
+ namespace=namespace,
179
+ key=key,
180
+ upload_id=upload_id,
181
+ ),
182
+ retry=True,
183
+ )