abdds 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.
abdds/download.py ADDED
@@ -0,0 +1,125 @@
1
+ """下载相关逻辑 - 异步流式下载、断点续传"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, AsyncGenerator
9
+
10
+ import aiofiles
11
+ import httpx
12
+
13
+ from ._http import UserAgent
14
+ from .errors import BaiduPanNetworkError
15
+
16
+ if TYPE_CHECKING:
17
+ from ._http import _HttpTransport
18
+
19
+ logger = logging.getLogger("abdds")
20
+
21
+
22
+ async def download(
23
+ transport: _HttpTransport,
24
+ dlink: str,
25
+ file_to: Path | None = None,
26
+ chunk_size: int = 1024 * 1024,
27
+ max_retries: int = 5,
28
+ ) -> AsyncGenerator[bytes, None] | Path:
29
+ """
30
+ 异步下载文件
31
+
32
+ - file_to=None: 返回异步字节迭代器 (流式下载)
33
+ - file_to=Path: 下载到本地文件,返回 Path
34
+
35
+ Args:
36
+ transport: 异步 HTTP 传输层
37
+ dlink: 下载链接
38
+ file_to: 本地保存路径 (Path),为 None 时返回迭代器
39
+ chunk_size: 分块大小,默认 1MB
40
+ max_retries: 最大重试次数,默认 5
41
+
42
+ Returns:
43
+ AsyncGenerator[bytes] 当 file_to=None,Path 当 file_to 指定路径
44
+
45
+ Raises:
46
+ TypeError: file_to 类型不正确
47
+ """
48
+ if file_to is not None and not isinstance(file_to, Path):
49
+ raise TypeError(
50
+ f"file_to must be Path or None, got {type(file_to).__name__}"
51
+ )
52
+ if chunk_size <= 0:
53
+ raise ValueError(f"chunk_size must be positive, got {chunk_size}")
54
+
55
+ stream = _download_stream(transport, dlink, chunk_size, max_retries)
56
+
57
+ if file_to is None:
58
+ return stream
59
+
60
+ file_to.parent.mkdir(parents=True, exist_ok=True)
61
+
62
+ async with aiofiles.open(file_to, "wb") as f:
63
+ async for chunk in stream:
64
+ await f.write(chunk)
65
+
66
+ logger.info("Downloaded to %s", file_to)
67
+ return file_to
68
+
69
+
70
+ async def _download_stream(
71
+ transport: _HttpTransport,
72
+ dlink: str,
73
+ chunk_size: int = 1024 * 1024,
74
+ max_retries: int = 5,
75
+ ) -> AsyncGenerator[bytes, None]:
76
+ """
77
+ 通过 dlink 异步流式下载文件,支持断点续传和指数重试
78
+ """
79
+ downloaded_bytes = 0
80
+ retry_count = 0
81
+
82
+ while True:
83
+ try:
84
+ headers = {"User-Agent": UserAgent}
85
+
86
+ if downloaded_bytes > 0:
87
+ headers["Range"] = f"bytes={downloaded_bytes}-"
88
+ logger.info("Resuming download from byte %d...", downloaded_bytes)
89
+
90
+ async with transport.client.stream(
91
+ "GET",
92
+ dlink,
93
+ headers=headers,
94
+ params={"access_token": transport._access_token},
95
+ timeout=httpx.Timeout(60, connect=15),
96
+ ) as response:
97
+ response.raise_for_status()
98
+
99
+ # 请求了 Range 但服务器返回 200,说明不支持断点续传
100
+ if downloaded_bytes > 0 and response.status_code == 200:
101
+ logger.warning("Server does not support Range, restarting from beginning")
102
+ downloaded_bytes = 0
103
+
104
+ async for chunk in response.aiter_bytes(chunk_size=chunk_size):
105
+ if chunk:
106
+ yield chunk
107
+ downloaded_bytes += len(chunk)
108
+
109
+ break # 下载完成
110
+
111
+ except httpx.RequestError as e:
112
+ retry_count += 1
113
+ if retry_count > max_retries:
114
+ raise BaiduPanNetworkError(
115
+ f"Download failed after {max_retries} retries "
116
+ f"({downloaded_bytes} bytes downloaded): {e}",
117
+ url=dlink,
118
+ ) from e
119
+
120
+ wait_time = 2 ** (retry_count - 1)
121
+ logger.warning(
122
+ "Download interrupted at %d bytes: %s. Retrying in %ds...",
123
+ downloaded_bytes, e, wait_time,
124
+ )
125
+ await asyncio.sleep(wait_time)
abdds/errors.py ADDED
@@ -0,0 +1,38 @@
1
+ """abdds 异常体系"""
2
+
3
+
4
+ class BaiduPanError(Exception):
5
+ """SDK 基础异常"""
6
+
7
+
8
+ class BaiduPanNetworkError(BaiduPanError):
9
+ """网络通信异常"""
10
+
11
+ def __init__(
12
+ self, message: str, *, url: str = "", status_code: int | None = None
13
+ ):
14
+ self.url = url
15
+ self.status_code = status_code
16
+ super().__init__(message)
17
+
18
+
19
+ class BaiduPanAPIError(BaiduPanError):
20
+ """API 业务异常 (errno != 0)"""
21
+
22
+ def __init__(
23
+ self, errno: int, errmsg: str = "", *, request_id: str = ""
24
+ ):
25
+ self.errno = errno
26
+ self.errmsg = errmsg
27
+ self.request_id = request_id
28
+ detail = f"[errno:{errno}] {errmsg}"
29
+ if request_id:
30
+ detail += f" (request_id:{request_id})"
31
+ super().__init__(detail)
32
+
33
+
34
+ class TokenExpiredError(BaiduPanError):
35
+ """Token 过期或无效"""
36
+
37
+ def __init__(self, message: str = "Access token expired or invalid"):
38
+ super().__init__(message)
abdds/models.py ADDED
@@ -0,0 +1,58 @@
1
+ """abdds 数据模型"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class UploadResult:
10
+ """上传结果"""
11
+
12
+ fs_id: int | None
13
+ md5: str
14
+ path: str
15
+ size: int | None
16
+
17
+
18
+ @dataclass
19
+ class _ApiPreUploadResponse:
20
+ """预上传响应(内部模型)"""
21
+
22
+ path: str
23
+ uploadid: str
24
+ block_list: list[int]
25
+ block_list_md5: list[str]
26
+ file_size: int
27
+
28
+
29
+ @dataclass
30
+ class ApiFileMeta:
31
+ """文件元信息"""
32
+
33
+ md5: str
34
+ path: str
35
+ size: int
36
+ fs_id: int
37
+ dlink: str
38
+ filename: str
39
+
40
+
41
+ @dataclass
42
+ class ApiFileListItem:
43
+ """文件列表项"""
44
+
45
+ md5: str
46
+ path: str
47
+ size: int
48
+ fs_id: int
49
+ is_dir: int
50
+ filename: str
51
+
52
+
53
+ @dataclass
54
+ class ApiQuotaInfo:
55
+ """空间配额信息"""
56
+
57
+ total: int
58
+ used: int
abdds/py.typed ADDED
File without changes
abdds/upload.py ADDED
@@ -0,0 +1,368 @@
1
+ """上传相关逻辑 - 异步小文件单步上传 & 大文件分片上传"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import inspect
7
+ import io
8
+ import json
9
+ import logging
10
+ from collections.abc import AsyncGenerator as _AsyncGeneratorABC
11
+ from collections.abc import Generator as _GeneratorABC
12
+ from pathlib import Path, PurePosixPath
13
+ from typing import TYPE_CHECKING, AsyncGenerator, Generator
14
+
15
+ import aiofiles
16
+ import aiofiles.os
17
+
18
+ from .errors import BaiduPanAPIError
19
+ from .models import _ApiPreUploadResponse, UploadResult
20
+
21
+ if TYPE_CHECKING:
22
+ from ._http import _HttpTransport
23
+
24
+ logger = logging.getLogger("abdds")
25
+
26
+ BLOCK_SIZE = 4 * 1024 * 1024 # 4MB
27
+
28
+ # file_from 参数可接受类型
29
+ UploadSource = Path | bytes | Generator[bytes, None, None] | AsyncGenerator[bytes, None]
30
+
31
+
32
+ def _is_generator(obj: object) -> bool:
33
+ """判断对象是否为同步生成器"""
34
+ return isinstance(obj, _GeneratorABC) and not isinstance(obj, _AsyncGeneratorABC)
35
+
36
+
37
+ def _is_async_generator(obj: object) -> bool:
38
+ """判断对象是否为异步生成器"""
39
+ return isinstance(obj, _AsyncGeneratorABC)
40
+
41
+
42
+ async def upload(
43
+ transport: _HttpTransport,
44
+ file_from: UploadSource,
45
+ to: PurePosixPath,
46
+ pan_dir_base: str,
47
+ ) -> UploadResult:
48
+ """
49
+ 异步上传数据到百度网盘
50
+
51
+ - 小文件 (<=4MB): 单步上传 (precreate + direct upload)
52
+ - 大文件 (>4MB): 分片上传 (precreate + 分片 + merge)
53
+
54
+ Args:
55
+ transport: 异步 HTTP 传输层
56
+ file_from: 数据来源,支持 Path / bytes / Generator[bytes] / AsyncGenerator[bytes]
57
+ to: 远程路径 (PurePosixPath),将拼接在 /apps/{app_name} 之后
58
+ pan_dir_base: 应用目录前缀,如 /apps/myapp
59
+
60
+ Raises:
61
+ TypeError: file_from 类型不正确
62
+ """
63
+ # 类型校验
64
+ if (
65
+ not isinstance(file_from, (Path, bytes))
66
+ and not _is_generator(file_from)
67
+ and not _is_async_generator(file_from)
68
+ ):
69
+ raise TypeError(
70
+ f"file_from must be Path, bytes, Generator[bytes], or AsyncGenerator[bytes], "
71
+ f"got {type(file_from).__name__}"
72
+ )
73
+
74
+ # 构造完整远程路径: pan_dir_base + to
75
+ remote_path = f"{pan_dir_base}{to.as_posix()}"
76
+
77
+ # 根据 file_from 类型提取数据
78
+ if isinstance(file_from, Path):
79
+ if not file_from.is_file():
80
+ raise FileNotFoundError(f"File not found: {file_from}")
81
+ file_size = (await aiofiles.os.stat(file_from)).st_size
82
+ if file_size == 0:
83
+ raise ValueError("Cannot upload empty file (0 bytes)")
84
+ block_md5_list = await _calculate_file_md5_blocks(file_from)
85
+ elif isinstance(file_from, bytes):
86
+ file_size = len(file_from)
87
+ if file_size == 0:
88
+ raise ValueError("Cannot upload empty data (0 bytes)")
89
+ block_md5_list = _calculate_bytes_md5_blocks(file_from)
90
+ elif _is_async_generator(file_from):
91
+ # 异步生成器需要消费一次来计算大小和 MD5,缓存在内存中
92
+ chunks: list[bytes] = []
93
+ file_size = 0
94
+ md5_list: list[str] = []
95
+ current_chunk = io.BytesIO()
96
+
97
+ async for data in file_from:
98
+ current_chunk.write(data)
99
+ file_size += len(data)
100
+ if current_chunk.tell() >= BLOCK_SIZE:
101
+ block_data = current_chunk.getvalue()
102
+ chunks.append(block_data)
103
+ md5_list.append(hashlib.md5(block_data).hexdigest())
104
+ current_chunk = io.BytesIO()
105
+
106
+ # 处理最后不足一个 BLOCK_SIZE 的数据
107
+ remaining = current_chunk.getvalue()
108
+ if remaining:
109
+ chunks.append(remaining)
110
+ md5_list.append(hashlib.md5(remaining).hexdigest())
111
+
112
+ block_md5_list = md5_list
113
+
114
+ if file_size == 0:
115
+ raise ValueError("Cannot upload empty async generator (0 bytes yielded)")
116
+
117
+ # 将 chunks 重新组装为异步生成器供上传使用
118
+ async def _replay_chunks() -> AsyncGenerator[bytes, None]:
119
+ for chunk in chunks:
120
+ yield chunk
121
+
122
+ file_from = _replay_chunks()
123
+ elif _is_generator(file_from):
124
+ # 同步生成器
125
+ chunks2: list[bytes] = []
126
+ file_size = 0
127
+ md5_list2: list[str] = []
128
+ current_chunk2 = io.BytesIO()
129
+
130
+ for data in file_from:
131
+ current_chunk2.write(data)
132
+ file_size += len(data)
133
+ if current_chunk2.tell() >= BLOCK_SIZE:
134
+ block_data = current_chunk2.getvalue()
135
+ chunks2.append(block_data)
136
+ md5_list2.append(hashlib.md5(block_data).hexdigest())
137
+ current_chunk2 = io.BytesIO()
138
+
139
+ remaining2 = current_chunk2.getvalue()
140
+ if remaining2:
141
+ chunks2.append(remaining2)
142
+ md5_list2.append(hashlib.md5(remaining2).hexdigest())
143
+
144
+ block_md5_list = md5_list2
145
+
146
+ if file_size == 0:
147
+ raise ValueError("Cannot upload empty generator (0 bytes yielded)")
148
+
149
+ file_from = (chunk for chunk in chunks2)
150
+ else:
151
+ raise TypeError(
152
+ f"file_from must be Path, bytes, Generator[bytes], or AsyncGenerator[bytes], "
153
+ f"got {type(file_from).__name__}"
154
+ )
155
+
156
+ logger.info("Uploading -> %s (%d bytes)", remote_path, file_size)
157
+
158
+ # 预上传
159
+ pre_resp = await _api_pre_create(transport, remote_path, file_size, block_md5_list)
160
+
161
+ # 获取上传服务器
162
+ upload_host = await _get_upload_server(transport, remote_path, pre_resp.uploadid)
163
+
164
+ if file_size <= BLOCK_SIZE:
165
+ return await _upload_small(transport, upload_host, pre_resp, file_from, file_size)
166
+ else:
167
+ return await _upload_multipart(transport, upload_host, pre_resp, file_from, block_md5_list)
168
+
169
+
170
+ async def _upload_small(
171
+ transport: _HttpTransport,
172
+ host: str,
173
+ pre_resp: _ApiPreUploadResponse,
174
+ file_from: UploadSource,
175
+ file_size: int,
176
+ ) -> UploadResult:
177
+ """小文件单步上传"""
178
+ url = f"{host}/rest/2.0/pcs/file"
179
+ params = {"method": "upload", "path": pre_resp.path}
180
+
181
+ if isinstance(file_from, Path):
182
+ async with aiofiles.open(file_from, "rb") as f:
183
+ data = await f.read()
184
+ elif isinstance(file_from, bytes):
185
+ data = file_from
186
+ elif _is_async_generator(file_from):
187
+ chunks: list[bytes] = []
188
+ async for chunk in file_from:
189
+ chunks.append(chunk)
190
+ data = b"".join(chunks)
191
+ elif _is_generator(file_from):
192
+ data = b"".join(file_from)
193
+ else:
194
+ data = b""
195
+
196
+ files = {"file": ("blob", data)}
197
+ resp = await transport.request("POST", url, params=params, files=files)
198
+ return UploadResult(
199
+ fs_id=resp.get("fs_id"),
200
+ md5=resp.get("md5"),
201
+ path=resp.get("path"),
202
+ size=resp.get("size"),
203
+ )
204
+
205
+
206
+ async def _upload_multipart(
207
+ transport: _HttpTransport,
208
+ host: str,
209
+ pre_resp: _ApiPreUploadResponse,
210
+ file_from: UploadSource,
211
+ block_md5_list: list[str],
212
+ ) -> UploadResult:
213
+ """大文件分片上传"""
214
+ url_prefix = f"{host}/rest/2.0/pcs/superfile2"
215
+
216
+ if isinstance(file_from, Path):
217
+ async with aiofiles.open(file_from, "rb") as f:
218
+ for idx in range(len(block_md5_list)):
219
+ await f.seek(idx * BLOCK_SIZE)
220
+ chunk_data = await f.read(BLOCK_SIZE)
221
+ await _upload_part(transport, url_prefix, pre_resp, idx, chunk_data)
222
+ elif isinstance(file_from, bytes):
223
+ for idx in range(len(block_md5_list)):
224
+ chunk_data = file_from[idx * BLOCK_SIZE : (idx + 1) * BLOCK_SIZE]
225
+ await _upload_part(transport, url_prefix, pre_resp, idx, chunk_data)
226
+ elif _is_async_generator(file_from):
227
+ idx = 0
228
+ async for chunk_data in file_from:
229
+ await _upload_part(transport, url_prefix, pre_resp, idx, chunk_data)
230
+ idx += 1
231
+ elif _is_generator(file_from):
232
+ for idx, chunk_data in enumerate(file_from):
233
+ await _upload_part(transport, url_prefix, pre_resp, idx, chunk_data)
234
+
235
+ # 合并分片
236
+ return await _api_create_file(
237
+ transport, pre_resp.path, pre_resp.uploadid, pre_resp.file_size, block_md5_list
238
+ )
239
+
240
+
241
+ async def _upload_part(
242
+ transport: _HttpTransport,
243
+ url_prefix: str,
244
+ pre_resp: _ApiPreUploadResponse,
245
+ partseq: int,
246
+ chunk_data: bytes,
247
+ ) -> None:
248
+ """上传单个分片"""
249
+ logger.debug("Uploading part %d", partseq + 1)
250
+ params = {
251
+ "method": "upload",
252
+ "type": "tmpfile",
253
+ "path": pre_resp.path,
254
+ "uploadid": pre_resp.uploadid,
255
+ "partseq": str(partseq),
256
+ }
257
+ files = {"file": ("blob", chunk_data)}
258
+ await transport.request("POST", url_prefix, params=params, files=files)
259
+
260
+
261
+ async def _api_pre_create(
262
+ transport: _HttpTransport,
263
+ path: str,
264
+ size: int,
265
+ block_list: list[str],
266
+ ) -> _ApiPreUploadResponse:
267
+ """预上传 - 通知云端新建上传任务"""
268
+ params = {"method": "precreate"}
269
+ data = {
270
+ "path": path,
271
+ "size": size,
272
+ "isdir": "0",
273
+ "autoinit": "1",
274
+ "rtype": "3", # 覆盖
275
+ "block_list": json.dumps(block_list),
276
+ }
277
+ resp = await transport.request("POST", "https://pan.baidu.com/rest/2.0/xpan/file", params=params, data=data)
278
+ return _ApiPreUploadResponse(
279
+ path=path,
280
+ uploadid=resp["uploadid"],
281
+ block_list=resp.get("block_list", []),
282
+ block_list_md5=block_list,
283
+ file_size=size,
284
+ )
285
+
286
+
287
+ async def _get_upload_server(transport: _HttpTransport, path: str, uploadid: str) -> str:
288
+ """获取上传服务器域名"""
289
+ params = {
290
+ "method": "locateupload",
291
+ "appid": "250528",
292
+ "path": path,
293
+ "uploadid": uploadid,
294
+ "upload_version": "2.0",
295
+ }
296
+ resp = await transport.request("GET", "https://pan.baidu.com/rest/2.0/xpan/file", params=params)
297
+ servers = resp.get("servers", [])
298
+ if not servers:
299
+ raise BaiduPanAPIError(-1, "No upload server available")
300
+
301
+ for s in servers:
302
+ server_url = s.get("server", "")
303
+ if server_url.startswith("https"):
304
+ return server_url
305
+
306
+ # 降级:返回第一个有效 server
307
+ for s in servers:
308
+ server_url = s.get("server", "")
309
+ if server_url:
310
+ return server_url
311
+
312
+ raise BaiduPanAPIError(-1, "No valid upload server in response")
313
+
314
+
315
+ async def _api_create_file(
316
+ transport: _HttpTransport,
317
+ path: str,
318
+ uploadid: str,
319
+ size: int,
320
+ block_list: list[str],
321
+ ) -> UploadResult:
322
+ """合并分片完成上传"""
323
+ params = {"method": "create"}
324
+ data = {
325
+ "path": path,
326
+ "size": size,
327
+ "isdir": "0",
328
+ "rtype": "3",
329
+ "uploadid": uploadid,
330
+ "block_list": json.dumps(block_list),
331
+ }
332
+ resp = await transport.request("POST", "https://pan.baidu.com/rest/2.0/xpan/file", params=params, data=data)
333
+ return UploadResult(
334
+ fs_id=resp.get("fs_id"),
335
+ md5=resp.get("md5"),
336
+ path=resp.get("path"),
337
+ size=resp.get("size"),
338
+ )
339
+
340
+
341
+ async def _calculate_file_md5_blocks(file_path: Path) -> list[str]:
342
+ """异步计算文件分片 MD5"""
343
+ file_size = (await aiofiles.os.stat(file_path)).st_size
344
+
345
+ async with aiofiles.open(file_path, "rb") as f:
346
+ if file_size <= BLOCK_SIZE:
347
+ content = await f.read()
348
+ return [hashlib.md5(content).hexdigest()]
349
+
350
+ md5_list: list[str] = []
351
+ while True:
352
+ chunk = await f.read(BLOCK_SIZE)
353
+ if not chunk:
354
+ break
355
+ md5_list.append(hashlib.md5(chunk).hexdigest())
356
+
357
+ return md5_list
358
+
359
+
360
+ def _calculate_bytes_md5_blocks(data: bytes) -> list[str]:
361
+ """计算 bytes 对象的分片 MD5"""
362
+ if len(data) <= BLOCK_SIZE:
363
+ return [hashlib.md5(data).hexdigest()]
364
+
365
+ md5_list: list[str] = []
366
+ for i in range(0, len(data), BLOCK_SIZE):
367
+ md5_list.append(hashlib.md5(data[i : i + BLOCK_SIZE]).hexdigest())
368
+ return md5_list