tamar-file-hub-client 0.0.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.
- file_hub_client/__init__.py +88 -0
- file_hub_client/client.py +414 -0
- file_hub_client/enums/__init__.py +12 -0
- file_hub_client/enums/export_format.py +16 -0
- file_hub_client/enums/role.py +7 -0
- file_hub_client/enums/upload_mode.py +11 -0
- file_hub_client/errors/__init__.py +30 -0
- file_hub_client/errors/exceptions.py +93 -0
- file_hub_client/py.typed +1 -0
- file_hub_client/rpc/__init__.py +10 -0
- file_hub_client/rpc/async_client.py +312 -0
- file_hub_client/rpc/gen/__init__.py +1 -0
- file_hub_client/rpc/gen/file_service_pb2.py +74 -0
- file_hub_client/rpc/gen/file_service_pb2_grpc.py +533 -0
- file_hub_client/rpc/gen/folder_service_pb2.py +53 -0
- file_hub_client/rpc/gen/folder_service_pb2_grpc.py +269 -0
- file_hub_client/rpc/generate_grpc.py +76 -0
- file_hub_client/rpc/protos/file_service.proto +147 -0
- file_hub_client/rpc/protos/folder_service.proto +65 -0
- file_hub_client/rpc/sync_client.py +313 -0
- file_hub_client/schemas/__init__.py +43 -0
- file_hub_client/schemas/context.py +160 -0
- file_hub_client/schemas/file.py +89 -0
- file_hub_client/schemas/folder.py +29 -0
- file_hub_client/services/__init__.py +17 -0
- file_hub_client/services/file/__init__.py +14 -0
- file_hub_client/services/file/async_blob_service.py +482 -0
- file_hub_client/services/file/async_file_service.py +257 -0
- file_hub_client/services/file/base_file_service.py +103 -0
- file_hub_client/services/file/sync_blob_service.py +478 -0
- file_hub_client/services/file/sync_file_service.py +255 -0
- file_hub_client/services/folder/__init__.py +10 -0
- file_hub_client/services/folder/async_folder_service.py +206 -0
- file_hub_client/services/folder/sync_folder_service.py +205 -0
- file_hub_client/utils/__init__.py +48 -0
- file_hub_client/utils/converter.py +108 -0
- file_hub_client/utils/download_helper.py +355 -0
- file_hub_client/utils/file_utils.py +105 -0
- file_hub_client/utils/retry.py +69 -0
- file_hub_client/utils/upload_helper.py +527 -0
- tamar_file_hub_client-0.0.1.dist-info/METADATA +874 -0
- tamar_file_hub_client-0.0.1.dist-info/RECORD +44 -0
- tamar_file_hub_client-0.0.1.dist-info/WHEEL +5 -0
- tamar_file_hub_client-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,478 @@
|
|
1
|
+
"""
|
2
|
+
同步二进制大对象服务
|
3
|
+
"""
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import Optional, Union, BinaryIO, Iterator
|
6
|
+
|
7
|
+
from .base_file_service import BaseFileService
|
8
|
+
from ...enums import UploadMode
|
9
|
+
from ...errors import ValidationError
|
10
|
+
from ...rpc import SyncGrpcClient
|
11
|
+
from ...schemas import FileUploadResponse, UploadUrlResponse
|
12
|
+
from ...utils import HttpUploader, HttpDownloader, retry_with_backoff, get_file_mime_type
|
13
|
+
|
14
|
+
|
15
|
+
class SyncBlobService(BaseFileService):
|
16
|
+
"""同步文件(二进制大对象)服务"""
|
17
|
+
|
18
|
+
def __init__(self, client: SyncGrpcClient):
|
19
|
+
"""
|
20
|
+
初始化文件(二进制大对象)服务
|
21
|
+
|
22
|
+
Args:
|
23
|
+
client: 同步gRPC客户端
|
24
|
+
"""
|
25
|
+
self.client = client
|
26
|
+
self.http_uploader = HttpUploader()
|
27
|
+
self.http_downloader = HttpDownloader()
|
28
|
+
|
29
|
+
def _generate_resumable_upload_url(
|
30
|
+
self,
|
31
|
+
file_name: str,
|
32
|
+
file_size: int,
|
33
|
+
folder_id: Optional[str] = None,
|
34
|
+
file_type: str = "file",
|
35
|
+
mime_type: str = None,
|
36
|
+
file_hash: str = None,
|
37
|
+
is_temporary: Optional[bool] = False,
|
38
|
+
expire_seconds: Optional[int] = None,
|
39
|
+
**metadata
|
40
|
+
) -> UploadUrlResponse:
|
41
|
+
"""
|
42
|
+
生成断点续传URL
|
43
|
+
|
44
|
+
Args:
|
45
|
+
file_name: 文件名
|
46
|
+
file_size: 文件大小
|
47
|
+
folder_id: 文件夹ID
|
48
|
+
file_type: 文件类型
|
49
|
+
mime_type: MIME类型
|
50
|
+
file_hash: 文件哈希
|
51
|
+
is_temporary: 是否为临时文件
|
52
|
+
expire_seconds: 过期秒数
|
53
|
+
**metadata: 额外的元数据(如 x-org-id, x-user-id 等)
|
54
|
+
|
55
|
+
Returns:
|
56
|
+
上传URL响应
|
57
|
+
"""
|
58
|
+
from ...rpc.gen import file_service_pb2, file_service_pb2_grpc
|
59
|
+
|
60
|
+
stub = self.client.get_stub(file_service_pb2_grpc.FileServiceStub)
|
61
|
+
|
62
|
+
request = file_service_pb2.UploadUrlRequest(
|
63
|
+
file_name=file_name,
|
64
|
+
file_size=file_size,
|
65
|
+
file_type=file_type,
|
66
|
+
mime_type=mime_type or "application/octet-stream",
|
67
|
+
file_hash=file_hash,
|
68
|
+
is_temporary=is_temporary,
|
69
|
+
expire_seconds=expire_seconds if is_temporary and expire_seconds else None,
|
70
|
+
)
|
71
|
+
|
72
|
+
if folder_id:
|
73
|
+
request.folder_id = folder_id
|
74
|
+
|
75
|
+
# 构建元数据
|
76
|
+
grpc_metadata = self.client.build_metadata(**metadata)
|
77
|
+
|
78
|
+
response = stub.GenerateResumableUploadUrl(request, metadata=grpc_metadata)
|
79
|
+
|
80
|
+
return UploadUrlResponse(
|
81
|
+
file=self._convert_file_info(response.file),
|
82
|
+
upload_file=self._convert_upload_file_info(response.upload_file),
|
83
|
+
upload_url=response.url
|
84
|
+
)
|
85
|
+
|
86
|
+
def _confirm_upload_completed(self, file_id: str, **metadata) -> None:
|
87
|
+
"""
|
88
|
+
确认上传完成
|
89
|
+
|
90
|
+
Args:
|
91
|
+
file_id: 文件ID
|
92
|
+
**metadata: 额外的元数据(如 x-org-id, x-user-id 等)
|
93
|
+
"""
|
94
|
+
from ...rpc.gen import file_service_pb2, file_service_pb2_grpc
|
95
|
+
|
96
|
+
stub = self.client.get_stub(file_service_pb2_grpc.FileServiceStub)
|
97
|
+
|
98
|
+
request = file_service_pb2.UploadCompletedRequest(file_id=file_id)
|
99
|
+
|
100
|
+
# 构建元数据
|
101
|
+
grpc_metadata = self.client.build_metadata(**metadata)
|
102
|
+
|
103
|
+
stub.ConfirmUploadCompleted(request, metadata=grpc_metadata)
|
104
|
+
|
105
|
+
@retry_with_backoff(max_retries=3)
|
106
|
+
def _upload_file(
|
107
|
+
self,
|
108
|
+
file_name: str,
|
109
|
+
content: Union[bytes, BinaryIO, Path],
|
110
|
+
folder_id: Optional[str] = None,
|
111
|
+
file_type: str = "file",
|
112
|
+
mime_type: Optional[str] = None,
|
113
|
+
is_temporary: Optional[bool] = False,
|
114
|
+
expire_seconds: Optional[int] = None,
|
115
|
+
**metadata
|
116
|
+
) -> FileUploadResponse:
|
117
|
+
"""
|
118
|
+
直接上传文件
|
119
|
+
|
120
|
+
Args:
|
121
|
+
file_name: 文件名
|
122
|
+
content: 文件内容(字节、文件对象或路径)
|
123
|
+
folder_id: 文件夹ID
|
124
|
+
file_type: 文件类型
|
125
|
+
mime_type: MIME类型
|
126
|
+
is_temporary: 是否为临时文件
|
127
|
+
expire_seconds: 过期秒数
|
128
|
+
**metadata: 额外的元数据(如 x-org-id, x-user-id 等)
|
129
|
+
|
130
|
+
Returns:
|
131
|
+
文件信息
|
132
|
+
"""
|
133
|
+
from ...rpc.gen import file_service_pb2, file_service_pb2_grpc
|
134
|
+
|
135
|
+
stub = self.client.get_stub(file_service_pb2_grpc.FileServiceStub)
|
136
|
+
|
137
|
+
# 处理不同类型的内容
|
138
|
+
if isinstance(content, Path):
|
139
|
+
if not content.exists():
|
140
|
+
raise ValidationError(f"文件不存在: {content}")
|
141
|
+
with open(content, "rb") as f:
|
142
|
+
file_bytes = f.read()
|
143
|
+
if not mime_type:
|
144
|
+
mime_type = get_file_mime_type(content)
|
145
|
+
elif isinstance(content, bytes):
|
146
|
+
file_bytes = content
|
147
|
+
elif hasattr(content, 'read'):
|
148
|
+
file_bytes = content.read()
|
149
|
+
else:
|
150
|
+
raise ValidationError("不支持的内容类型")
|
151
|
+
|
152
|
+
# 构建请求
|
153
|
+
request = file_service_pb2.UploadFileRequest(
|
154
|
+
file_name=file_name,
|
155
|
+
content=file_bytes,
|
156
|
+
file_type=file_type,
|
157
|
+
mime_type=mime_type or "application/octet-stream",
|
158
|
+
is_temporary=is_temporary,
|
159
|
+
expire_seconds=expire_seconds,
|
160
|
+
)
|
161
|
+
|
162
|
+
if folder_id:
|
163
|
+
request.folder_id = folder_id
|
164
|
+
|
165
|
+
# 构建元数据
|
166
|
+
grpc_metadata = self.client.build_metadata(**metadata)
|
167
|
+
|
168
|
+
# 发送请求
|
169
|
+
response = stub.UploadFile(request, metadata=grpc_metadata)
|
170
|
+
|
171
|
+
# 转换响应
|
172
|
+
return FileUploadResponse(
|
173
|
+
file=self._convert_file_info(response.file),
|
174
|
+
upload_file=self._convert_upload_file_info(response.upload_file),
|
175
|
+
)
|
176
|
+
|
177
|
+
def _upload_stream(
|
178
|
+
self,
|
179
|
+
file_name: str,
|
180
|
+
content: Union[bytes, BinaryIO, Path],
|
181
|
+
file_size: int,
|
182
|
+
folder_id: Optional[str],
|
183
|
+
file_type: str,
|
184
|
+
mime_type: str,
|
185
|
+
file_hash: str,
|
186
|
+
is_temporary: Optional[bool] = False,
|
187
|
+
expire_seconds: Optional[int] = None,
|
188
|
+
**metadata
|
189
|
+
) -> FileUploadResponse:
|
190
|
+
"""客户端直传实现"""
|
191
|
+
# 获取上传URL,以及对应的文件和上传文件信息
|
192
|
+
upload_url_resp = self.generate_upload_url(
|
193
|
+
file_name=file_name,
|
194
|
+
file_size=file_size,
|
195
|
+
folder_id=folder_id,
|
196
|
+
file_type=file_type,
|
197
|
+
mime_type=mime_type,
|
198
|
+
file_hash=file_hash,
|
199
|
+
is_temporary=is_temporary,
|
200
|
+
expire_seconds=expire_seconds if is_temporary and expire_seconds else None,
|
201
|
+
**metadata
|
202
|
+
)
|
203
|
+
|
204
|
+
# 上传文件到对象存储
|
205
|
+
self.http_uploader.upload(
|
206
|
+
url=upload_url_resp.upload_url,
|
207
|
+
content=content,
|
208
|
+
headers={"Content-Type": mime_type},
|
209
|
+
total_size=file_size,
|
210
|
+
)
|
211
|
+
|
212
|
+
# 确认上传完成
|
213
|
+
self._confirm_upload_completed(
|
214
|
+
file_id=upload_url_resp.file.id,
|
215
|
+
**metadata
|
216
|
+
)
|
217
|
+
|
218
|
+
# 返回文件信息
|
219
|
+
return FileUploadResponse(
|
220
|
+
file=upload_url_resp.file,
|
221
|
+
upload_file=upload_url_resp.upload_file
|
222
|
+
)
|
223
|
+
|
224
|
+
def _upload_resumable(
|
225
|
+
self,
|
226
|
+
file_name: str,
|
227
|
+
content: Union[bytes, BinaryIO, Path],
|
228
|
+
file_size: int,
|
229
|
+
folder_id: Optional[str],
|
230
|
+
file_type: str,
|
231
|
+
mime_type: str,
|
232
|
+
file_hash: str,
|
233
|
+
is_temporary: Optional[bool] = False,
|
234
|
+
expire_seconds: Optional[int] = None,
|
235
|
+
**metadata
|
236
|
+
) -> FileUploadResponse:
|
237
|
+
"""断点续传实现"""
|
238
|
+
# 获取断点续传URL,以及对应的文件和上传文件信息
|
239
|
+
upload_url_resp = self._generate_resumable_upload_url(
|
240
|
+
file_name=file_name,
|
241
|
+
file_size=file_size,
|
242
|
+
folder_id=folder_id,
|
243
|
+
file_type=file_type,
|
244
|
+
mime_type=mime_type,
|
245
|
+
file_hash=file_hash,
|
246
|
+
is_temporary=is_temporary,
|
247
|
+
expire_seconds=expire_seconds if is_temporary and expire_seconds else None,
|
248
|
+
**metadata
|
249
|
+
)
|
250
|
+
|
251
|
+
# 开启断点续传
|
252
|
+
upload_url = self.http_uploader.start_resumable_session(
|
253
|
+
url=upload_url_resp.upload_url,
|
254
|
+
total_file_size=file_size,
|
255
|
+
mine_type=mime_type,
|
256
|
+
)
|
257
|
+
|
258
|
+
# 上传文件到对象存储
|
259
|
+
self.http_uploader.upload(
|
260
|
+
url=upload_url,
|
261
|
+
content=content,
|
262
|
+
headers={"Content-Type": mime_type},
|
263
|
+
total_size=file_size,
|
264
|
+
is_resume=True
|
265
|
+
)
|
266
|
+
|
267
|
+
# 确认上传完成
|
268
|
+
self._confirm_upload_completed(
|
269
|
+
file_id=upload_url_resp.file.id,
|
270
|
+
**metadata
|
271
|
+
)
|
272
|
+
|
273
|
+
# 返回文件信息
|
274
|
+
return FileUploadResponse(
|
275
|
+
file=upload_url_resp.file,
|
276
|
+
upload_file=upload_url_resp.upload_file
|
277
|
+
)
|
278
|
+
|
279
|
+
def generate_upload_url(
|
280
|
+
self,
|
281
|
+
file_name: str,
|
282
|
+
file_size: int,
|
283
|
+
folder_id: Optional[str] = None,
|
284
|
+
file_type: str = "file",
|
285
|
+
mime_type: str = None,
|
286
|
+
file_hash: str = None,
|
287
|
+
is_temporary: Optional[bool] = False,
|
288
|
+
expire_seconds: Optional[int] = None,
|
289
|
+
**metadata
|
290
|
+
) -> UploadUrlResponse:
|
291
|
+
"""
|
292
|
+
生成上传URL(用于客户端直传)
|
293
|
+
|
294
|
+
Args:
|
295
|
+
file_name: 文件名
|
296
|
+
file_size: 文件大小
|
297
|
+
folder_id: 文件夹ID
|
298
|
+
file_type: 文件类型
|
299
|
+
mime_type: MIME类型
|
300
|
+
file_hash: 文件哈希
|
301
|
+
is_temporary: 是否为临时文件
|
302
|
+
expire_seconds: 过期秒数
|
303
|
+
**metadata: 额外的元数据(如 x-org-id, x-user-id 等)
|
304
|
+
|
305
|
+
Returns:
|
306
|
+
上传URL响应
|
307
|
+
"""
|
308
|
+
from ...rpc.gen import file_service_pb2, file_service_pb2_grpc
|
309
|
+
|
310
|
+
stub = self.client.get_stub(file_service_pb2_grpc.FileServiceStub)
|
311
|
+
|
312
|
+
request = file_service_pb2.UploadUrlRequest(
|
313
|
+
file_name=file_name,
|
314
|
+
file_size=file_size,
|
315
|
+
file_type=file_type,
|
316
|
+
mime_type=mime_type or "application/octet-stream",
|
317
|
+
file_hash=file_hash,
|
318
|
+
is_temporary=is_temporary,
|
319
|
+
expire_seconds=expire_seconds if is_temporary and expire_seconds else None,
|
320
|
+
)
|
321
|
+
|
322
|
+
if folder_id:
|
323
|
+
request.folder_id = folder_id
|
324
|
+
|
325
|
+
# 构建元数据
|
326
|
+
grpc_metadata = self.client.build_metadata(**metadata)
|
327
|
+
|
328
|
+
response = stub.GenerateUploadUrl(request, metadata=grpc_metadata)
|
329
|
+
|
330
|
+
return UploadUrlResponse(
|
331
|
+
file=self._convert_file_info(response.file),
|
332
|
+
upload_file=self._convert_upload_file_info(response.upload_file),
|
333
|
+
upload_url=response.url
|
334
|
+
)
|
335
|
+
|
336
|
+
def upload(
|
337
|
+
self,
|
338
|
+
file: Union[str, Path, BinaryIO, bytes],
|
339
|
+
*,
|
340
|
+
folder_id: Optional[str] = None,
|
341
|
+
mode: Optional[UploadMode] = UploadMode.NORMAL,
|
342
|
+
is_temporary: Optional[bool] = False,
|
343
|
+
expire_seconds: Optional[int] = None,
|
344
|
+
**metadata
|
345
|
+
) -> FileUploadResponse:
|
346
|
+
"""
|
347
|
+
统一的文件上传接口
|
348
|
+
|
349
|
+
Args:
|
350
|
+
file: 文件路径、Path对象、文件对象或字节数据
|
351
|
+
folder_id: 目标文件夹ID(可选)
|
352
|
+
mode: 上传模式(NORMAL/DIRECT/RESUMABLE/STREAM)
|
353
|
+
is_temporary: 是否为临时文件
|
354
|
+
expire_seconds: 过期秒数
|
355
|
+
**metadata: 额外的元数据
|
356
|
+
|
357
|
+
Returns:
|
358
|
+
文件信息
|
359
|
+
"""
|
360
|
+
# 解析文件参数,提取文件信息
|
361
|
+
file_name, content, file_size, mime_type, file_type, file_hash = self._extract_file_info(file)
|
362
|
+
|
363
|
+
# 根据文件大小自动选择上传模式
|
364
|
+
if mode == UploadMode.NORMAL:
|
365
|
+
ten_mb = 1024 * 1024 * 10
|
366
|
+
hundred_mb = 1024 * 1024 * 100
|
367
|
+
if file_size >= ten_mb and file_size < hundred_mb: # 10MB
|
368
|
+
mode = UploadMode.STREAM # 大文件自动使用流式上传模式
|
369
|
+
elif file_size > hundred_mb:
|
370
|
+
mode = UploadMode.RESUMABLE # 特大文件自动使用断点续传模式
|
371
|
+
|
372
|
+
# 根据上传模式执行不同的上传逻辑
|
373
|
+
if mode == UploadMode.NORMAL:
|
374
|
+
# 普通上传(通过gRPC)
|
375
|
+
return self._upload_file(
|
376
|
+
file_name=file_name,
|
377
|
+
content=content,
|
378
|
+
folder_id=folder_id,
|
379
|
+
file_type=file_type,
|
380
|
+
mime_type=mime_type,
|
381
|
+
is_temporary=is_temporary,
|
382
|
+
expire_seconds=expire_seconds if is_temporary and expire_seconds else None,
|
383
|
+
**metadata
|
384
|
+
)
|
385
|
+
|
386
|
+
elif mode == UploadMode.STREAM:
|
387
|
+
# 流式上传
|
388
|
+
return self._upload_stream(
|
389
|
+
file_name=file_name,
|
390
|
+
content=content,
|
391
|
+
file_size=file_size,
|
392
|
+
folder_id=folder_id,
|
393
|
+
file_type=file_type,
|
394
|
+
mime_type=mime_type,
|
395
|
+
file_hash=file_hash,
|
396
|
+
is_temporary=is_temporary,
|
397
|
+
expire_seconds=expire_seconds if is_temporary and expire_seconds else None,
|
398
|
+
**metadata
|
399
|
+
)
|
400
|
+
|
401
|
+
elif mode == UploadMode.RESUMABLE:
|
402
|
+
# 断点续传
|
403
|
+
return self._upload_resumable(
|
404
|
+
file_name=file_name,
|
405
|
+
content=content,
|
406
|
+
file_size=file_size,
|
407
|
+
folder_id=folder_id,
|
408
|
+
file_type=file_type,
|
409
|
+
mime_type=mime_type,
|
410
|
+
file_hash=file_hash,
|
411
|
+
is_temporary=is_temporary,
|
412
|
+
expire_seconds=expire_seconds if is_temporary and expire_seconds else None,
|
413
|
+
**metadata
|
414
|
+
)
|
415
|
+
|
416
|
+
else:
|
417
|
+
raise ValidationError(f"不支持的上传模式: {mode}")
|
418
|
+
|
419
|
+
def generate_download_url(
|
420
|
+
self,
|
421
|
+
file_id: str,
|
422
|
+
*,
|
423
|
+
expire_seconds: Optional[int] = None,
|
424
|
+
**metadata
|
425
|
+
) -> str:
|
426
|
+
"""
|
427
|
+
生成下载URL
|
428
|
+
|
429
|
+
Args:
|
430
|
+
file_id: 文件ID
|
431
|
+
**metadata: 额外的元数据(如 x-org-id, x-user-id 等)
|
432
|
+
|
433
|
+
Returns:
|
434
|
+
下载URL
|
435
|
+
"""
|
436
|
+
from ...rpc.gen import file_service_pb2, file_service_pb2_grpc
|
437
|
+
|
438
|
+
stub = self.client.get_stub(file_service_pb2_grpc.FileServiceStub)
|
439
|
+
|
440
|
+
request = file_service_pb2.DownloadUrlRequest(file_id=file_id,
|
441
|
+
expire_seconds=expire_seconds if expire_seconds else None)
|
442
|
+
|
443
|
+
# 构建元数据
|
444
|
+
grpc_metadata = self.client.build_metadata(**metadata)
|
445
|
+
|
446
|
+
download_url_resp = stub.GenerateDownloadUrl(request, metadata=grpc_metadata)
|
447
|
+
|
448
|
+
return download_url_resp.url
|
449
|
+
|
450
|
+
def download(
|
451
|
+
self,
|
452
|
+
file_id: str,
|
453
|
+
save_path: Optional[Union[str, Path]] = None,
|
454
|
+
chunk_size: Optional[int] = None,
|
455
|
+
**metadata
|
456
|
+
) -> Union[bytes, Path, Iterator[bytes]]:
|
457
|
+
"""
|
458
|
+
统一的文件下载接口
|
459
|
+
|
460
|
+
Args:
|
461
|
+
file_id: 文件ID
|
462
|
+
save_path: 保存路径(如果为None,返回字节数据)
|
463
|
+
chunk_size: 分片大小
|
464
|
+
**metadata: 额外的元数据
|
465
|
+
|
466
|
+
Returns:
|
467
|
+
- NORMAL模式:下载的内容(字节)或保存的文件路径
|
468
|
+
- STREAM模式:返回迭代器,逐块返回数据
|
469
|
+
"""
|
470
|
+
|
471
|
+
# 获取下载URL
|
472
|
+
download_url = self.generate_download_url(file_id, **metadata)
|
473
|
+
|
474
|
+
return self.http_downloader.download(
|
475
|
+
url=download_url,
|
476
|
+
save_path=save_path,
|
477
|
+
chunk_size=chunk_size,
|
478
|
+
)
|