bdds 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.
- bdds/__init__.py +39 -0
- bdds/_http.py +156 -0
- bdds/client.py +390 -0
- bdds/download.py +124 -0
- bdds/errors.py +38 -0
- bdds/models.py +58 -0
- bdds/py.typed +0 -0
- bdds/upload.py +308 -0
- bdds-0.1.0.dist-info/METADATA +168 -0
- bdds-0.1.0.dist-info/RECORD +13 -0
- bdds-0.1.0.dist-info/WHEEL +5 -0
- bdds-0.1.0.dist-info/licenses/LICENSE +21 -0
- bdds-0.1.0.dist-info/top_level.txt +1 -0
bdds/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""bdds - 百度网盘 Python SDK (baidu-disk-sdk)
|
|
2
|
+
|
|
3
|
+
简单、有效、报错清晰的百度网盘同步 SDK。
|
|
4
|
+
|
|
5
|
+
Usage::
|
|
6
|
+
|
|
7
|
+
from bdds import BaiduPanClient
|
|
8
|
+
|
|
9
|
+
with BaiduPanClient(client_id, client_secret, app_name) as client:
|
|
10
|
+
quota = client.get_quota()
|
|
11
|
+
client.upload(Path("file.txt"), file_to=Path("/docs/file.txt"))
|
|
12
|
+
client.download(dlink, file_to=Path("output.txt"))
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
from .client import BaiduPanClient
|
|
18
|
+
from .errors import (
|
|
19
|
+
BaiduPanAPIError,
|
|
20
|
+
BaiduPanError,
|
|
21
|
+
BaiduPanNetworkError,
|
|
22
|
+
TokenExpiredError,
|
|
23
|
+
)
|
|
24
|
+
from .models import ApiFileListItem, ApiFileMeta, ApiQuotaInfo, UploadResult
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
# Client
|
|
28
|
+
"BaiduPanClient",
|
|
29
|
+
# Errors
|
|
30
|
+
"BaiduPanError",
|
|
31
|
+
"BaiduPanNetworkError",
|
|
32
|
+
"BaiduPanAPIError",
|
|
33
|
+
"TokenExpiredError",
|
|
34
|
+
# Models
|
|
35
|
+
"UploadResult",
|
|
36
|
+
"ApiFileMeta",
|
|
37
|
+
"ApiFileListItem",
|
|
38
|
+
"ApiQuotaInfo",
|
|
39
|
+
]
|
bdds/_http.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""HTTP 传输层 - Session 管理、请求封装、重试策略"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from requests.adapters import HTTPAdapter
|
|
11
|
+
from urllib3.util.retry import Retry
|
|
12
|
+
|
|
13
|
+
from .errors import BaiduPanAPIError, BaiduPanNetworkError, TokenExpiredError
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("bdds")
|
|
16
|
+
|
|
17
|
+
UserAgent = "netdisk;2.2.51.6;netdisk;10.0;PC;PC;Mac OS X 10.15.7;en-US"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _truncate_data(data: Any, max_len: int = 500) -> Any:
|
|
21
|
+
"""递归截断超长字符串和二进制数据,用于日志安全输出"""
|
|
22
|
+
if isinstance(data, dict):
|
|
23
|
+
return {k: _truncate_data(v, max_len) for k, v in data.items()}
|
|
24
|
+
if isinstance(data, (list, tuple, set)):
|
|
25
|
+
return [_truncate_data(i, max_len) for i in data]
|
|
26
|
+
if isinstance(data, (bytes, bytearray)):
|
|
27
|
+
return f"<Binary Data: {len(data)} bytes>"
|
|
28
|
+
if isinstance(data, str) and len(data) > max_len:
|
|
29
|
+
return f"{data[:max_len]}... [truncated]"
|
|
30
|
+
return data
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _HttpTransport:
|
|
34
|
+
"""封装 requests.Session,提供自动 Token 注入、重试和错误处理"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
max_retries: int = 3,
|
|
39
|
+
timeout: tuple[int, int] = (100, 600),
|
|
40
|
+
) -> None:
|
|
41
|
+
self._max_retries = max_retries
|
|
42
|
+
self._timeout = timeout
|
|
43
|
+
self._session = self._init_session()
|
|
44
|
+
self._access_token: str | None = None
|
|
45
|
+
self._on_token_expired: Callable[[], str | None] | None = None
|
|
46
|
+
|
|
47
|
+
def _init_session(self) -> requests.Session:
|
|
48
|
+
session = requests.Session()
|
|
49
|
+
retries = Retry(
|
|
50
|
+
total=self._max_retries,
|
|
51
|
+
backoff_factor=1,
|
|
52
|
+
status_forcelist=[500, 502, 503, 504],
|
|
53
|
+
allowed_methods=["GET", "POST", "PUT", "DELETE"],
|
|
54
|
+
raise_on_status=True,
|
|
55
|
+
)
|
|
56
|
+
adapter = HTTPAdapter(max_retries=retries)
|
|
57
|
+
session.mount("http://", adapter)
|
|
58
|
+
session.mount("https://", adapter)
|
|
59
|
+
session.headers.update({"User-Agent": UserAgent})
|
|
60
|
+
return session
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def session(self) -> requests.Session:
|
|
64
|
+
return self._session
|
|
65
|
+
|
|
66
|
+
def set_access_token(self, token: str | None) -> None:
|
|
67
|
+
self._access_token = token
|
|
68
|
+
|
|
69
|
+
def set_token_expired_callback(self, callback: Callable[[], str | None]) -> None:
|
|
70
|
+
"""设置 Token 过期时的刷新回调,回调应返回新的 access_token"""
|
|
71
|
+
self._on_token_expired = callback
|
|
72
|
+
|
|
73
|
+
def request(self, method: str, url: str, **kwargs: Any) -> dict | requests.Response:
|
|
74
|
+
"""
|
|
75
|
+
核心请求方法:
|
|
76
|
+
1. 自动注入 access_token
|
|
77
|
+
2. 处理网络层错误 (由 Session Retry 处理重试)
|
|
78
|
+
3. 处理业务层错误 (解析 errno)
|
|
79
|
+
4. Token 失效时自动刷新并重试一次
|
|
80
|
+
"""
|
|
81
|
+
logger.debug("method:%s url:%s kwargs:%s", method, url, _truncate_data(kwargs))
|
|
82
|
+
|
|
83
|
+
if self._session is None:
|
|
84
|
+
raise RuntimeError("Transport is closed, cannot make requests")
|
|
85
|
+
|
|
86
|
+
if not self._access_token:
|
|
87
|
+
raise TokenExpiredError("Access token not initialized, please login first")
|
|
88
|
+
|
|
89
|
+
params = kwargs.pop("params", {})
|
|
90
|
+
params["access_token"] = self._access_token
|
|
91
|
+
|
|
92
|
+
kwargs.setdefault("timeout", self._timeout)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
response = self._session.request(method, url, params=params, **kwargs)
|
|
96
|
+
response.raise_for_status()
|
|
97
|
+
|
|
98
|
+
# 流式响应直接返回
|
|
99
|
+
if kwargs.get("stream"):
|
|
100
|
+
return response # type: ignore[return-value]
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
data = response.json()
|
|
104
|
+
except (json.JSONDecodeError, ValueError):
|
|
105
|
+
raise BaiduPanAPIError(-1, "Invalid JSON response")
|
|
106
|
+
logger.debug("method:%s url:%s response:%s", method, url, _truncate_data(data))
|
|
107
|
+
|
|
108
|
+
except requests.RequestException as e:
|
|
109
|
+
raise BaiduPanNetworkError(
|
|
110
|
+
f"Request failed: {e}", url=url
|
|
111
|
+
) from e
|
|
112
|
+
|
|
113
|
+
# 业务错误检查
|
|
114
|
+
errno = data.get("errno", 0)
|
|
115
|
+
if errno != 0:
|
|
116
|
+
# Token 失效 (errno: -6 或 11)
|
|
117
|
+
if errno in (-6, 11) and self._on_token_expired:
|
|
118
|
+
logger.warning("Token expired during request, refreshing...")
|
|
119
|
+
new_token = self._on_token_expired()
|
|
120
|
+
if new_token:
|
|
121
|
+
params["access_token"] = new_token
|
|
122
|
+
try:
|
|
123
|
+
response = self._session.request(
|
|
124
|
+
method, url, params=params, **kwargs
|
|
125
|
+
)
|
|
126
|
+
response.raise_for_status()
|
|
127
|
+
data = response.json()
|
|
128
|
+
except requests.RequestException as e:
|
|
129
|
+
raise BaiduPanNetworkError(
|
|
130
|
+
f"Retry after token refresh failed: {e}", url=url
|
|
131
|
+
) from e
|
|
132
|
+
except (json.JSONDecodeError, ValueError):
|
|
133
|
+
raise BaiduPanAPIError(-1, "Invalid JSON response")
|
|
134
|
+
|
|
135
|
+
if data.get("errno", 0) == 0:
|
|
136
|
+
return data
|
|
137
|
+
|
|
138
|
+
# 刷新后仍失败
|
|
139
|
+
raise BaiduPanAPIError(
|
|
140
|
+
data.get("errno", errno),
|
|
141
|
+
data.get("errmsg", "Token refresh failed"),
|
|
142
|
+
request_id=data.get("request_id", ""),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
raise BaiduPanAPIError(
|
|
146
|
+
errno,
|
|
147
|
+
data.get("errmsg", "Unknown error"),
|
|
148
|
+
request_id=data.get("request_id", ""),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return data
|
|
152
|
+
|
|
153
|
+
def close(self) -> None:
|
|
154
|
+
if self._session is not None:
|
|
155
|
+
self._session.close()
|
|
156
|
+
self._session = None
|
bdds/client.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""BaiduPanClient - 百度网盘 Python SDK 主类"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import stat
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
from typing import Generator
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
from ._http import _HttpTransport
|
|
16
|
+
from .download import download as _download
|
|
17
|
+
from .errors import BaiduPanNetworkError, TokenExpiredError
|
|
18
|
+
from .models import ApiFileListItem, ApiFileMeta, ApiQuotaInfo, UploadResult
|
|
19
|
+
from .upload import upload as _upload
|
|
20
|
+
from .upload import UploadSource, _is_generator
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("bdds")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BaiduPanClient:
|
|
26
|
+
"""
|
|
27
|
+
百度网盘 Python SDK 客户端
|
|
28
|
+
|
|
29
|
+
具备自动重试、Token 管理、流式下载和分片上传。
|
|
30
|
+
|
|
31
|
+
Usage::
|
|
32
|
+
|
|
33
|
+
with BaiduPanClient(client_id, client_secret, app_name) as client:
|
|
34
|
+
if not client.access_token:
|
|
35
|
+
print(client.auth_url)
|
|
36
|
+
code = input("Enter code: ")
|
|
37
|
+
client.fetch_token(code)
|
|
38
|
+
|
|
39
|
+
quota = client.get_quota()
|
|
40
|
+
result = client.upload(Path("local_file.txt"))
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
HOST_OAUTH = "https://openapi.baidu.com/oauth/2.0"
|
|
44
|
+
HOST_PAN = "https://pan.baidu.com"
|
|
45
|
+
|
|
46
|
+
URL_QUOTA = f"{HOST_PAN}/api/quota"
|
|
47
|
+
URL_FILE = f"{HOST_PAN}/rest/2.0/xpan/file"
|
|
48
|
+
URL_MULTIMEDIA = f"{HOST_PAN}/rest/2.0/xpan/multimedia"
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
client_id: str,
|
|
53
|
+
client_secret: str,
|
|
54
|
+
app_name: str,
|
|
55
|
+
config_dir: Path | None = None,
|
|
56
|
+
max_retries: int = 3,
|
|
57
|
+
timeout: tuple[int, int] | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""
|
|
60
|
+
初始化百度网盘客户端
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
client_id: 百度开放平台应用的 API Key
|
|
64
|
+
client_secret: 百度开放平台应用的 Secret Key
|
|
65
|
+
app_name: 应用名称
|
|
66
|
+
config_dir: Token 存储目录,默认 ~/.baidupan
|
|
67
|
+
max_retries: 请求重试次数,默认 3
|
|
68
|
+
timeout: 请求超时 (connect, read),默认 (100, 600)
|
|
69
|
+
"""
|
|
70
|
+
self._client_id = client_id
|
|
71
|
+
self._client_secret = client_secret
|
|
72
|
+
self.app_name = app_name
|
|
73
|
+
self.pan_dir_base = f"/apps/{app_name}"
|
|
74
|
+
|
|
75
|
+
if config_dir is None:
|
|
76
|
+
config_dir = Path.home() / ".baidupan"
|
|
77
|
+
self.config_dir = config_dir
|
|
78
|
+
self._token_file = self.config_dir / f"access_token_{client_id}.json"
|
|
79
|
+
|
|
80
|
+
self._token_data: dict = {}
|
|
81
|
+
self._access_token: str | None = None
|
|
82
|
+
self._refresh_token: str | None = None
|
|
83
|
+
|
|
84
|
+
# 初始化 HTTP 传输层
|
|
85
|
+
self._transport = _HttpTransport(
|
|
86
|
+
max_retries=max_retries,
|
|
87
|
+
timeout=timeout or (100, 600),
|
|
88
|
+
)
|
|
89
|
+
self._transport.set_token_expired_callback(self._handle_token_expired)
|
|
90
|
+
|
|
91
|
+
# 加载已保存的 Token
|
|
92
|
+
self._load_token()
|
|
93
|
+
|
|
94
|
+
# ---- 上下文管理器 ----
|
|
95
|
+
|
|
96
|
+
def close(self) -> None:
|
|
97
|
+
"""关闭客户端,释放资源"""
|
|
98
|
+
self._transport.close()
|
|
99
|
+
|
|
100
|
+
def __enter__(self) -> BaiduPanClient:
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object) -> bool | None:
|
|
104
|
+
self.close()
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
# ---- Token 管理 ----
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def access_token(self) -> str | None:
|
|
111
|
+
"""当前 access_token(只读)"""
|
|
112
|
+
return self._access_token
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def auth_url(self) -> str:
|
|
116
|
+
"""生成用户授权 URL"""
|
|
117
|
+
return (
|
|
118
|
+
f"{self.HOST_OAUTH}/authorize?"
|
|
119
|
+
f"response_type=code&client_id={self._client_id}&redirect_uri=oob&scope=basic,netdisk"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def fetch_token(self, code: str) -> None:
|
|
123
|
+
"""
|
|
124
|
+
通过授权码获取 Access Token
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
code: 用户授权后获得的授权码
|
|
128
|
+
"""
|
|
129
|
+
url = f"{self.HOST_OAUTH}/token"
|
|
130
|
+
params = {
|
|
131
|
+
"grant_type": "authorization_code",
|
|
132
|
+
"code": code,
|
|
133
|
+
"client_id": self._client_id,
|
|
134
|
+
"client_secret": self._client_secret,
|
|
135
|
+
"redirect_uri": "oob",
|
|
136
|
+
}
|
|
137
|
+
try:
|
|
138
|
+
resp = self._transport.session.get(url, params=params, timeout=10)
|
|
139
|
+
resp.raise_for_status()
|
|
140
|
+
result = resp.json()
|
|
141
|
+
except requests.RequestException as e:
|
|
142
|
+
raise BaiduPanNetworkError(f"Fetch token failed: {e}") from e
|
|
143
|
+
|
|
144
|
+
# 检查 API 层面错误
|
|
145
|
+
if "error" in result:
|
|
146
|
+
raise TokenExpiredError(
|
|
147
|
+
f"Fetch token failed: {result.get('error_description', result['error'])}"
|
|
148
|
+
)
|
|
149
|
+
self._save_token(result)
|
|
150
|
+
|
|
151
|
+
def refresh_token(self) -> str:
|
|
152
|
+
"""
|
|
153
|
+
刷新 Access Token
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
新的 access_token
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
TokenExpiredError: 无 refresh_token 可用
|
|
160
|
+
"""
|
|
161
|
+
if not self._refresh_token:
|
|
162
|
+
raise TokenExpiredError("No refresh token available, please re-login")
|
|
163
|
+
|
|
164
|
+
url = f"{self.HOST_OAUTH}/token"
|
|
165
|
+
params = {
|
|
166
|
+
"grant_type": "refresh_token",
|
|
167
|
+
"refresh_token": self._refresh_token,
|
|
168
|
+
"client_id": self._client_id,
|
|
169
|
+
"client_secret": self._client_secret,
|
|
170
|
+
}
|
|
171
|
+
try:
|
|
172
|
+
resp = self._transport.session.get(url, params=params, timeout=10)
|
|
173
|
+
resp.raise_for_status()
|
|
174
|
+
result = resp.json()
|
|
175
|
+
except requests.RequestException as e:
|
|
176
|
+
raise BaiduPanNetworkError(f"Refresh token failed: {e}") from e
|
|
177
|
+
|
|
178
|
+
# 检查 API 层面错误
|
|
179
|
+
if "error" in result:
|
|
180
|
+
raise TokenExpiredError(
|
|
181
|
+
f"Refresh token failed: {result.get('error_description', result['error'])}"
|
|
182
|
+
)
|
|
183
|
+
self._save_token(result)
|
|
184
|
+
return self._access_token # type: ignore[return-value]
|
|
185
|
+
|
|
186
|
+
def _handle_token_expired(self) -> str | None:
|
|
187
|
+
"""Token 过期回调,供 _HttpTransport 调用"""
|
|
188
|
+
try:
|
|
189
|
+
return self.refresh_token()
|
|
190
|
+
except (TokenExpiredError, BaiduPanNetworkError):
|
|
191
|
+
logger.error("Token refresh failed in auto-retry")
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
def _load_token(self) -> None:
|
|
195
|
+
"""从本地文件加载 Token"""
|
|
196
|
+
if not self._token_file.exists():
|
|
197
|
+
return
|
|
198
|
+
try:
|
|
199
|
+
data = json.loads(self._token_file.read_text(encoding="utf-8"))
|
|
200
|
+
self._token_data = data
|
|
201
|
+
self._access_token = data.get("access_token")
|
|
202
|
+
self._refresh_token = data.get("refresh_token")
|
|
203
|
+
self._transport.set_access_token(self._access_token)
|
|
204
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
205
|
+
logger.error("Failed to load token: %s", e)
|
|
206
|
+
|
|
207
|
+
def _save_token(self, data: dict) -> None:
|
|
208
|
+
"""持久化保存 Token"""
|
|
209
|
+
if "access_token" not in data:
|
|
210
|
+
error_desc = data.get("error_description", data.get("Error", "Unknown error"))
|
|
211
|
+
raise TokenExpiredError(f"Token response missing access_token: {error_desc}")
|
|
212
|
+
|
|
213
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
data["update_at"] = int(time.time())
|
|
215
|
+
self._token_data = data
|
|
216
|
+
self._access_token = data["access_token"]
|
|
217
|
+
self._refresh_token = data.get("refresh_token")
|
|
218
|
+
self._transport.set_access_token(self._access_token)
|
|
219
|
+
|
|
220
|
+
self._token_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
221
|
+
# 限制文件权限为仅拥有者可读写
|
|
222
|
+
try:
|
|
223
|
+
self._token_file.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
224
|
+
except OSError:
|
|
225
|
+
pass # Windows 不完全支持 chmod
|
|
226
|
+
|
|
227
|
+
logger.info("Access token saved")
|
|
228
|
+
|
|
229
|
+
# ---- 业务 API ----
|
|
230
|
+
|
|
231
|
+
def get_quota(self) -> ApiQuotaInfo:
|
|
232
|
+
"""
|
|
233
|
+
获取网盘空间配额
|
|
234
|
+
|
|
235
|
+
https://pan.baidu.com/union/doc/Cksg0s9ic
|
|
236
|
+
"""
|
|
237
|
+
data = self._transport.request("GET", self.URL_QUOTA)
|
|
238
|
+
return ApiQuotaInfo(total=data.get("total", 0), used=data.get("used", 0))
|
|
239
|
+
|
|
240
|
+
def get_file_metas(self, fsids: list[int]) -> list[ApiFileMeta]:
|
|
241
|
+
"""
|
|
242
|
+
获取文件元信息
|
|
243
|
+
|
|
244
|
+
https://pan.baidu.com/union/doc/Fksg0sbcm
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
fsids: 文件 ID 列表
|
|
248
|
+
"""
|
|
249
|
+
if not fsids:
|
|
250
|
+
return []
|
|
251
|
+
|
|
252
|
+
params = {"method": "filemetas", "fsids": json.dumps(fsids), "dlink": 1}
|
|
253
|
+
data = self._transport.request("GET", self.URL_MULTIMEDIA, params=params)
|
|
254
|
+
items = data.get("list", [])
|
|
255
|
+
|
|
256
|
+
return [
|
|
257
|
+
ApiFileMeta(
|
|
258
|
+
fs_id=item["fs_id"],
|
|
259
|
+
filename=item["filename"],
|
|
260
|
+
path=item["path"],
|
|
261
|
+
size=item["size"],
|
|
262
|
+
md5=item.get("md5", ""),
|
|
263
|
+
dlink=item.get("dlink", ""),
|
|
264
|
+
)
|
|
265
|
+
for item in items
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
def get_file_list(self, path: str) -> list[ApiFileListItem]:
|
|
269
|
+
"""
|
|
270
|
+
获取指定目录下的文件列表
|
|
271
|
+
|
|
272
|
+
https://pan.baidu.com/union/doc/nksg0sat9
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
path: 目录路径
|
|
276
|
+
"""
|
|
277
|
+
params = {"method": "list", "dir": quote(path)}
|
|
278
|
+
data = self._transport.request("GET", self.URL_FILE, params=params)
|
|
279
|
+
items = data.get("list", [])
|
|
280
|
+
|
|
281
|
+
return [
|
|
282
|
+
ApiFileListItem(
|
|
283
|
+
fs_id=item["fs_id"],
|
|
284
|
+
filename=item["server_filename"],
|
|
285
|
+
path=item["path"],
|
|
286
|
+
size=item["size"],
|
|
287
|
+
md5=item.get("md5", ""),
|
|
288
|
+
is_dir=item.get("isdir", 0),
|
|
289
|
+
)
|
|
290
|
+
for item in items
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
def delete_files(self, file_paths: list[str]) -> None:
|
|
294
|
+
"""
|
|
295
|
+
批量删除文件
|
|
296
|
+
|
|
297
|
+
https://pan.baidu.com/union/doc/mksg0s9l4
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
file_paths: 要删除的文件路径列表
|
|
301
|
+
"""
|
|
302
|
+
if not file_paths:
|
|
303
|
+
return
|
|
304
|
+
params = {"method": "filemanager", "opera": "delete"}
|
|
305
|
+
data = {"async": 2, "filelist": json.dumps(file_paths)}
|
|
306
|
+
self._transport.request("POST", self.URL_FILE, params=params, data=data)
|
|
307
|
+
logger.info("Deleted %d file(s)", len(file_paths))
|
|
308
|
+
|
|
309
|
+
# ---- 上传 ----
|
|
310
|
+
|
|
311
|
+
def upload(
|
|
312
|
+
self,
|
|
313
|
+
file_from: UploadSource,
|
|
314
|
+
file_to: Path | None = None,
|
|
315
|
+
) -> UploadResult:
|
|
316
|
+
"""
|
|
317
|
+
上传数据到百度网盘
|
|
318
|
+
|
|
319
|
+
- 小文件 (<=4MB): 单步上传
|
|
320
|
+
- 大文件 (>4MB): 分片上传
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
file_from: 数据来源,支持 Path / bytes / Generator[bytes]
|
|
324
|
+
file_to: 远程路径 (Path),拼接在 /apps/{app_name} 之后。
|
|
325
|
+
默认使用 file_from 的文件名 (Path 时) 或 "upload"
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
UploadResult: 上传结果
|
|
329
|
+
|
|
330
|
+
Raises:
|
|
331
|
+
TypeError: file_from 或 file_to 类型不正确
|
|
332
|
+
"""
|
|
333
|
+
# 类型校验
|
|
334
|
+
if not isinstance(file_from, (Path, bytes)) and not _is_generator(file_from):
|
|
335
|
+
raise TypeError(
|
|
336
|
+
f"file_from must be Path, bytes, or Generator[bytes], "
|
|
337
|
+
f"got {type(file_from).__name__}"
|
|
338
|
+
)
|
|
339
|
+
if file_to is not None and not isinstance(file_to, Path):
|
|
340
|
+
raise TypeError(
|
|
341
|
+
f"file_to must be Path or None, got {type(file_to).__name__}"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
if isinstance(file_from, Path):
|
|
345
|
+
default_name = file_from.name
|
|
346
|
+
else:
|
|
347
|
+
default_name = "upload"
|
|
348
|
+
|
|
349
|
+
if file_to is None:
|
|
350
|
+
remote_path = PurePosixPath(f"/{default_name}")
|
|
351
|
+
else:
|
|
352
|
+
# 将 Path 转为 PurePosixPath 以确保 POSIX 风格
|
|
353
|
+
remote_path = PurePosixPath(file_to.as_posix())
|
|
354
|
+
if not remote_path.is_absolute():
|
|
355
|
+
remote_path = PurePosixPath(f"/{remote_path}")
|
|
356
|
+
|
|
357
|
+
return _upload(self._transport, file_from, remote_path, self.pan_dir_base)
|
|
358
|
+
|
|
359
|
+
# ---- 下载 ----
|
|
360
|
+
|
|
361
|
+
def download(
|
|
362
|
+
self,
|
|
363
|
+
dlink: str,
|
|
364
|
+
file_to: Path | None = None,
|
|
365
|
+
chunk_size: int = 1024 * 1024,
|
|
366
|
+
) -> Generator[bytes, None, None] | Path:
|
|
367
|
+
"""
|
|
368
|
+
下载文件
|
|
369
|
+
|
|
370
|
+
- file_to=None: 返回字节迭代器 (流式下载)
|
|
371
|
+
- file_to=Path: 下载到本地文件,返回 Path
|
|
372
|
+
|
|
373
|
+
https://pan.baidu.com/union/doc/pkuo3snyp
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
dlink: 下载链接 (通过 get_file_metas 获取)
|
|
377
|
+
file_to: 本地保存路径 (Path),为 None 时返回迭代器
|
|
378
|
+
chunk_size: 分块大小,默认 1MB
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
Generator[bytes] 当 file_to=None,Path 当 file_to 指定路径
|
|
382
|
+
|
|
383
|
+
Raises:
|
|
384
|
+
TypeError: file_to 类型不正确
|
|
385
|
+
"""
|
|
386
|
+
if file_to is not None and not isinstance(file_to, Path):
|
|
387
|
+
raise TypeError(
|
|
388
|
+
f"file_to must be Path or None, got {type(file_to).__name__}"
|
|
389
|
+
)
|
|
390
|
+
return _download(self._transport, dlink, file_to, chunk_size)
|
bdds/download.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""下载相关逻辑 - 流式下载、断点续传"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Generator
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from ._http import UserAgent
|
|
13
|
+
from .errors import BaiduPanNetworkError
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ._http import _HttpTransport
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("bdds")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def download(
|
|
22
|
+
transport: _HttpTransport,
|
|
23
|
+
dlink: str,
|
|
24
|
+
file_to: Path | None = None,
|
|
25
|
+
chunk_size: int = 1024 * 1024,
|
|
26
|
+
max_retries: int = 5,
|
|
27
|
+
) -> Generator[bytes, None, None] | Path:
|
|
28
|
+
"""
|
|
29
|
+
下载文件
|
|
30
|
+
|
|
31
|
+
- file_to=None: 返回字节迭代器 (流式下载)
|
|
32
|
+
- file_to=Path: 下载到本地文件,返回 Path
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
transport: HTTP 传输层
|
|
36
|
+
dlink: 下载链接
|
|
37
|
+
file_to: 本地保存路径 (Path),为 None 时返回迭代器
|
|
38
|
+
chunk_size: 分块大小,默认 1MB
|
|
39
|
+
max_retries: 最大重试次数,默认 5
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Generator[bytes] 当 file_to=None,Path 当 file_to 指定路径
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
TypeError: file_to 类型不正确
|
|
46
|
+
"""
|
|
47
|
+
if file_to is not None and not isinstance(file_to, Path):
|
|
48
|
+
raise TypeError(
|
|
49
|
+
f"file_to must be Path or None, got {type(file_to).__name__}"
|
|
50
|
+
)
|
|
51
|
+
if chunk_size <= 0:
|
|
52
|
+
raise ValueError(f"chunk_size must be positive, got {chunk_size}")
|
|
53
|
+
|
|
54
|
+
stream = _download_stream(transport, dlink, chunk_size, max_retries)
|
|
55
|
+
|
|
56
|
+
if file_to is None:
|
|
57
|
+
return stream
|
|
58
|
+
|
|
59
|
+
file_to.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
|
|
61
|
+
with open(file_to, "wb") as f:
|
|
62
|
+
for chunk in stream:
|
|
63
|
+
f.write(chunk)
|
|
64
|
+
|
|
65
|
+
logger.info("Downloaded to %s", file_to)
|
|
66
|
+
return file_to
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _download_stream(
|
|
70
|
+
transport: _HttpTransport,
|
|
71
|
+
dlink: str,
|
|
72
|
+
chunk_size: int = 1024 * 1024,
|
|
73
|
+
max_retries: int = 5,
|
|
74
|
+
) -> Generator[bytes, None, None]:
|
|
75
|
+
"""
|
|
76
|
+
通过 dlink 流式下载文件,支持断点续传和指数重试
|
|
77
|
+
"""
|
|
78
|
+
downloaded_bytes = 0
|
|
79
|
+
retry_count = 0
|
|
80
|
+
|
|
81
|
+
while True:
|
|
82
|
+
try:
|
|
83
|
+
headers = {"User-Agent": UserAgent}
|
|
84
|
+
|
|
85
|
+
if downloaded_bytes > 0:
|
|
86
|
+
headers["Range"] = f"bytes={downloaded_bytes}-"
|
|
87
|
+
logger.info("Resuming download from byte %d...", downloaded_bytes)
|
|
88
|
+
|
|
89
|
+
response = transport.session.get(
|
|
90
|
+
dlink,
|
|
91
|
+
headers=headers,
|
|
92
|
+
params={"access_token": transport._access_token},
|
|
93
|
+
stream=True,
|
|
94
|
+
timeout=(15, 60),
|
|
95
|
+
)
|
|
96
|
+
response.raise_for_status()
|
|
97
|
+
|
|
98
|
+
# 请求了 Range 但服务器返回 200,说明不支持断点续传
|
|
99
|
+
if downloaded_bytes > 0 and response.status_code == 200:
|
|
100
|
+
logger.warning("Server does not support Range, restarting from beginning")
|
|
101
|
+
downloaded_bytes = 0
|
|
102
|
+
|
|
103
|
+
for chunk in response.iter_content(chunk_size=chunk_size):
|
|
104
|
+
if chunk:
|
|
105
|
+
yield chunk
|
|
106
|
+
downloaded_bytes += len(chunk)
|
|
107
|
+
|
|
108
|
+
break # 下载完成
|
|
109
|
+
|
|
110
|
+
except requests.RequestException as e:
|
|
111
|
+
retry_count += 1
|
|
112
|
+
if retry_count > max_retries:
|
|
113
|
+
raise BaiduPanNetworkError(
|
|
114
|
+
f"Download failed after {max_retries} retries "
|
|
115
|
+
f"({downloaded_bytes} bytes downloaded): {e}",
|
|
116
|
+
url=dlink,
|
|
117
|
+
) from e
|
|
118
|
+
|
|
119
|
+
wait_time = 2 ** (retry_count - 1)
|
|
120
|
+
logger.warning(
|
|
121
|
+
"Download interrupted at %d bytes: %s. Retrying in %ds...",
|
|
122
|
+
downloaded_bytes, e, wait_time,
|
|
123
|
+
)
|
|
124
|
+
time.sleep(wait_time)
|
bdds/errors.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""bdds 异常体系"""
|
|
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)
|
bdds/models.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""bdds 数据模型"""
|
|
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
|
bdds/py.typed
ADDED
|
File without changes
|
bdds/upload.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""上传相关逻辑 - 小文件单步上传 & 大文件分片上传"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from collections.abc import Generator as _GeneratorABC
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
from typing import TYPE_CHECKING, Generator
|
|
12
|
+
|
|
13
|
+
from .errors import BaiduPanAPIError
|
|
14
|
+
from .models import _ApiPreUploadResponse, UploadResult
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ._http import _HttpTransport
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("bdds")
|
|
20
|
+
|
|
21
|
+
BLOCK_SIZE = 4 * 1024 * 1024 # 4MB
|
|
22
|
+
|
|
23
|
+
# file_from 参数可接受类型
|
|
24
|
+
UploadSource = Path | bytes | Generator[bytes, None, None]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def upload(
|
|
28
|
+
transport: _HttpTransport,
|
|
29
|
+
file_from: UploadSource,
|
|
30
|
+
to: PurePosixPath,
|
|
31
|
+
pan_dir_base: str,
|
|
32
|
+
) -> UploadResult:
|
|
33
|
+
"""
|
|
34
|
+
上传数据到百度网盘
|
|
35
|
+
|
|
36
|
+
- 小文件 (<=4MB): 单步上传 (precreate + direct upload)
|
|
37
|
+
- 大文件 (>4MB): 分片上传 (precreate + 分片 + merge)
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
transport: HTTP 传输层
|
|
41
|
+
file_from: 数据来源,支持 Path / bytes / Generator[bytes]
|
|
42
|
+
to: 远程路径 (PurePosixPath),将拼接在 /apps/{app_name} 之后
|
|
43
|
+
pan_dir_base: 应用目录前缀,如 /apps/myapp
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
TypeError: file_from 类型不正确
|
|
47
|
+
"""
|
|
48
|
+
# 类型校验
|
|
49
|
+
if not isinstance(file_from, (Path, bytes)) and not _is_generator(file_from):
|
|
50
|
+
raise TypeError(
|
|
51
|
+
f"file_from must be Path, bytes, or Generator[bytes], "
|
|
52
|
+
f"got {type(file_from).__name__}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# 构造完整远程路径: pan_dir_base + to
|
|
56
|
+
remote_path = f"{pan_dir_base}{to.as_posix()}"
|
|
57
|
+
|
|
58
|
+
# 根据 file_from 类型提取数据
|
|
59
|
+
if isinstance(file_from, Path):
|
|
60
|
+
if not file_from.is_file():
|
|
61
|
+
raise FileNotFoundError(f"File not found: {file_from}")
|
|
62
|
+
file_size = file_from.stat().st_size
|
|
63
|
+
if file_size == 0:
|
|
64
|
+
raise ValueError("Cannot upload empty file (0 bytes)")
|
|
65
|
+
block_md5_list = _calculate_file_md5_blocks(file_from)
|
|
66
|
+
elif isinstance(file_from, bytes):
|
|
67
|
+
file_size = len(file_from)
|
|
68
|
+
if file_size == 0:
|
|
69
|
+
raise ValueError("Cannot upload empty data (0 bytes)")
|
|
70
|
+
block_md5_list = _calculate_bytes_md5_blocks(file_from)
|
|
71
|
+
elif _is_generator(file_from):
|
|
72
|
+
# 生成器需要消费一次来计算大小和 MD5,缓存在内存中
|
|
73
|
+
chunks: list[bytes] = []
|
|
74
|
+
file_size = 0
|
|
75
|
+
md5_list: list[str] = []
|
|
76
|
+
current_chunk = io.BytesIO()
|
|
77
|
+
|
|
78
|
+
for data in file_from:
|
|
79
|
+
current_chunk.write(data)
|
|
80
|
+
file_size += len(data)
|
|
81
|
+
if current_chunk.tell() >= BLOCK_SIZE:
|
|
82
|
+
block_data = current_chunk.getvalue()
|
|
83
|
+
chunks.append(block_data)
|
|
84
|
+
md5_list.append(hashlib.md5(block_data).hexdigest())
|
|
85
|
+
current_chunk = io.BytesIO()
|
|
86
|
+
|
|
87
|
+
# 处理最后不足一个 BLOCK_SIZE 的数据
|
|
88
|
+
remaining = current_chunk.getvalue()
|
|
89
|
+
if remaining:
|
|
90
|
+
chunks.append(remaining)
|
|
91
|
+
md5_list.append(hashlib.md5(remaining).hexdigest())
|
|
92
|
+
|
|
93
|
+
block_md5_list = md5_list
|
|
94
|
+
|
|
95
|
+
if file_size == 0:
|
|
96
|
+
raise ValueError("Cannot upload empty generator (0 bytes yielded)")
|
|
97
|
+
|
|
98
|
+
# 将 chunks 重新组装为生成器供上传使用
|
|
99
|
+
file_from = (chunk for chunk in chunks)
|
|
100
|
+
else:
|
|
101
|
+
raise TypeError(f"file_from must be Path, bytes, or Generator[bytes], got {type(file_from).__name__}")
|
|
102
|
+
|
|
103
|
+
logger.info("Uploading -> %s (%d bytes)", remote_path, file_size)
|
|
104
|
+
|
|
105
|
+
# 预上传
|
|
106
|
+
pre_resp = _api_pre_create(transport, remote_path, file_size, block_md5_list)
|
|
107
|
+
|
|
108
|
+
# 获取上传服务器
|
|
109
|
+
upload_host = _get_upload_server(transport, remote_path, pre_resp.uploadid)
|
|
110
|
+
|
|
111
|
+
if file_size <= BLOCK_SIZE:
|
|
112
|
+
return _upload_small(transport, upload_host, pre_resp, file_from, file_size)
|
|
113
|
+
else:
|
|
114
|
+
return _upload_multipart(transport, upload_host, pre_resp, file_from, block_md5_list)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_generator(obj: object) -> bool:
|
|
118
|
+
"""判断对象是否为生成器"""
|
|
119
|
+
return isinstance(obj, _GeneratorABC)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _upload_small(
|
|
123
|
+
transport: _HttpTransport,
|
|
124
|
+
host: str,
|
|
125
|
+
pre_resp: _ApiPreUploadResponse,
|
|
126
|
+
file_from: UploadSource,
|
|
127
|
+
file_size: int,
|
|
128
|
+
) -> UploadResult:
|
|
129
|
+
"""小文件单步上传"""
|
|
130
|
+
url = f"{host}/rest/2.0/pcs/file"
|
|
131
|
+
params = {"method": "upload", "path": pre_resp.path}
|
|
132
|
+
|
|
133
|
+
if isinstance(file_from, Path):
|
|
134
|
+
data = file_from.read_bytes()
|
|
135
|
+
elif isinstance(file_from, bytes):
|
|
136
|
+
data = file_from
|
|
137
|
+
else:
|
|
138
|
+
# 生成器
|
|
139
|
+
data = b"".join(file_from)
|
|
140
|
+
|
|
141
|
+
files = {"file": ("blob", data)}
|
|
142
|
+
resp = transport.request("POST", url, params=params, files=files)
|
|
143
|
+
return UploadResult(
|
|
144
|
+
fs_id=resp.get("fs_id"),
|
|
145
|
+
md5=resp.get("md5"),
|
|
146
|
+
path=resp.get("path"),
|
|
147
|
+
size=resp.get("size"),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _upload_multipart(
|
|
152
|
+
transport: _HttpTransport,
|
|
153
|
+
host: str,
|
|
154
|
+
pre_resp: _ApiPreUploadResponse,
|
|
155
|
+
file_from: UploadSource,
|
|
156
|
+
block_md5_list: list[str],
|
|
157
|
+
) -> UploadResult:
|
|
158
|
+
"""大文件分片上传"""
|
|
159
|
+
url_prefix = f"{host}/rest/2.0/pcs/superfile2"
|
|
160
|
+
|
|
161
|
+
if isinstance(file_from, Path):
|
|
162
|
+
with open(file_from, "rb") as f:
|
|
163
|
+
for idx in range(len(block_md5_list)):
|
|
164
|
+
f.seek(idx * BLOCK_SIZE)
|
|
165
|
+
chunk_data = f.read(BLOCK_SIZE)
|
|
166
|
+
_upload_part(transport, url_prefix, pre_resp, idx, chunk_data)
|
|
167
|
+
elif isinstance(file_from, bytes):
|
|
168
|
+
for idx in range(len(block_md5_list)):
|
|
169
|
+
chunk_data = file_from[idx * BLOCK_SIZE : (idx + 1) * BLOCK_SIZE]
|
|
170
|
+
_upload_part(transport, url_prefix, pre_resp, idx, chunk_data)
|
|
171
|
+
else:
|
|
172
|
+
# 生成器(已在上层收集为 chunks 列表后重建的生成器)
|
|
173
|
+
for idx, chunk_data in enumerate(file_from):
|
|
174
|
+
_upload_part(transport, url_prefix, pre_resp, idx, chunk_data)
|
|
175
|
+
|
|
176
|
+
# 合并分片
|
|
177
|
+
return _api_create_file(
|
|
178
|
+
transport, pre_resp.path, pre_resp.uploadid, pre_resp.file_size, block_md5_list
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _upload_part(
|
|
183
|
+
transport: _HttpTransport,
|
|
184
|
+
url_prefix: str,
|
|
185
|
+
pre_resp: _ApiPreUploadResponse,
|
|
186
|
+
partseq: int,
|
|
187
|
+
chunk_data: bytes,
|
|
188
|
+
) -> None:
|
|
189
|
+
"""上传单个分片"""
|
|
190
|
+
logger.debug("Uploading part %d", partseq + 1)
|
|
191
|
+
params = {
|
|
192
|
+
"method": "upload",
|
|
193
|
+
"type": "tmpfile",
|
|
194
|
+
"path": pre_resp.path,
|
|
195
|
+
"uploadid": pre_resp.uploadid,
|
|
196
|
+
"partseq": str(partseq),
|
|
197
|
+
}
|
|
198
|
+
files = {"file": ("blob", chunk_data)}
|
|
199
|
+
transport.request("POST", url_prefix, params=params, files=files)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _api_pre_create(
|
|
203
|
+
transport: _HttpTransport,
|
|
204
|
+
path: str,
|
|
205
|
+
size: int,
|
|
206
|
+
block_list: list[str],
|
|
207
|
+
) -> _ApiPreUploadResponse:
|
|
208
|
+
"""预上传 - 通知云端新建上传任务"""
|
|
209
|
+
params = {"method": "precreate"}
|
|
210
|
+
data = {
|
|
211
|
+
"path": path,
|
|
212
|
+
"size": size,
|
|
213
|
+
"isdir": "0",
|
|
214
|
+
"autoinit": "1",
|
|
215
|
+
"rtype": "3", # 覆盖
|
|
216
|
+
"block_list": json.dumps(block_list),
|
|
217
|
+
}
|
|
218
|
+
resp = transport.request("POST", "https://pan.baidu.com/rest/2.0/xpan/file", params=params, data=data)
|
|
219
|
+
return _ApiPreUploadResponse(
|
|
220
|
+
path=path,
|
|
221
|
+
uploadid=resp["uploadid"],
|
|
222
|
+
block_list=resp.get("block_list", []),
|
|
223
|
+
block_list_md5=block_list,
|
|
224
|
+
file_size=size,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _get_upload_server(transport: _HttpTransport, path: str, uploadid: str) -> str:
|
|
229
|
+
"""获取上传服务器域名"""
|
|
230
|
+
params = {
|
|
231
|
+
"method": "locateupload",
|
|
232
|
+
"appid": "250528",
|
|
233
|
+
"path": path,
|
|
234
|
+
"uploadid": uploadid,
|
|
235
|
+
"upload_version": "2.0",
|
|
236
|
+
}
|
|
237
|
+
resp = transport.request("GET", "https://pan.baidu.com/rest/2.0/xpan/file", params=params)
|
|
238
|
+
servers = resp.get("servers", [])
|
|
239
|
+
if not servers:
|
|
240
|
+
raise BaiduPanAPIError(-1, "No upload server available")
|
|
241
|
+
|
|
242
|
+
for s in servers:
|
|
243
|
+
server_url = s.get("server", "")
|
|
244
|
+
if server_url.startswith("https"):
|
|
245
|
+
return server_url
|
|
246
|
+
|
|
247
|
+
# 降级:返回第一个有效 server
|
|
248
|
+
for s in servers:
|
|
249
|
+
server_url = s.get("server", "")
|
|
250
|
+
if server_url:
|
|
251
|
+
return server_url
|
|
252
|
+
|
|
253
|
+
raise BaiduPanAPIError(-1, "No valid upload server in response")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _api_create_file(
|
|
257
|
+
transport: _HttpTransport,
|
|
258
|
+
path: str,
|
|
259
|
+
uploadid: str,
|
|
260
|
+
size: int,
|
|
261
|
+
block_list: list[str],
|
|
262
|
+
) -> UploadResult:
|
|
263
|
+
"""合并分片完成上传"""
|
|
264
|
+
params = {"method": "create"}
|
|
265
|
+
data = {
|
|
266
|
+
"path": path,
|
|
267
|
+
"size": size,
|
|
268
|
+
"isdir": "0",
|
|
269
|
+
"rtype": "3",
|
|
270
|
+
"uploadid": uploadid,
|
|
271
|
+
"block_list": json.dumps(block_list),
|
|
272
|
+
}
|
|
273
|
+
resp = transport.request("POST", "https://pan.baidu.com/rest/2.0/xpan/file", params=params, data=data)
|
|
274
|
+
return UploadResult(
|
|
275
|
+
fs_id=resp.get("fs_id"),
|
|
276
|
+
md5=resp.get("md5"),
|
|
277
|
+
path=resp.get("path"),
|
|
278
|
+
size=resp.get("size"),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _calculate_file_md5_blocks(file_path: Path) -> list[str]:
|
|
283
|
+
"""高效计算文件分片 MD5"""
|
|
284
|
+
file_size = file_path.stat().st_size
|
|
285
|
+
|
|
286
|
+
with open(file_path, "rb") as f:
|
|
287
|
+
if file_size <= BLOCK_SIZE:
|
|
288
|
+
return [hashlib.md5(f.read()).hexdigest()]
|
|
289
|
+
|
|
290
|
+
md5_list: list[str] = []
|
|
291
|
+
while True:
|
|
292
|
+
chunk = f.read(BLOCK_SIZE)
|
|
293
|
+
if not chunk:
|
|
294
|
+
break
|
|
295
|
+
md5_list.append(hashlib.md5(chunk).hexdigest())
|
|
296
|
+
|
|
297
|
+
return md5_list
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _calculate_bytes_md5_blocks(data: bytes) -> list[str]:
|
|
301
|
+
"""计算 bytes 对象的分片 MD5"""
|
|
302
|
+
if len(data) <= BLOCK_SIZE:
|
|
303
|
+
return [hashlib.md5(data).hexdigest()]
|
|
304
|
+
|
|
305
|
+
md5_list: list[str] = []
|
|
306
|
+
for i in range(0, len(data), BLOCK_SIZE):
|
|
307
|
+
md5_list.append(hashlib.md5(data[i : i + BLOCK_SIZE]).hexdigest())
|
|
308
|
+
return md5_list
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bdds
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Baidu Disk SDK - 百度网盘 Python SDK
|
|
5
|
+
Author: hochenggang
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hochenggang
|
|
8
|
+
Project-URL: Repository, https://github.com/hochenggang/bdds
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/hochenggang/bdds/issues
|
|
10
|
+
Keywords: baidu,pan,netdisk,sdk,baidupan,baidu-disk
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: requests>=2.20
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-mock>=3.0; extra == "dev"
|
|
27
|
+
Requires-Dist: responses>=0.23; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# bdds
|
|
31
|
+
|
|
32
|
+
Baidu Disk SDK - 简单、有效、报错清晰的百度网盘 Python SDK。
|
|
33
|
+
|
|
34
|
+
基于 [百度网盘开放平台 API](https://pan.baidu.com/union/doc/) 封装,核心依赖仅 `requests`。
|
|
35
|
+
|
|
36
|
+
## 安装
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install bdds
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## 快速开始
|
|
43
|
+
|
|
44
|
+
### 1. 获取应用凭证
|
|
45
|
+
|
|
46
|
+
在 [百度网盘开放平台](https://pan.baidu.com/union/doc/) 创建应用,获取 `client_id`、`client_secret` 和 `app_name`。
|
|
47
|
+
|
|
48
|
+
### 2. 初始化客户端
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
from bdds import BaiduPanClient
|
|
53
|
+
|
|
54
|
+
client = BaiduPanClient(
|
|
55
|
+
client_id="your_client_id",
|
|
56
|
+
client_secret="your_client_secret",
|
|
57
|
+
app_name="your_app_name",
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 3. 授权登录
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
# 首次使用需要授权
|
|
65
|
+
if not client.access_token:
|
|
66
|
+
print(f"请在浏览器打开: {client.auth_url}")
|
|
67
|
+
code = input("请输入授权码: ")
|
|
68
|
+
client.fetch_token(code)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Token 会自动保存到 `~/.baidupan/` 目录,下次启动无需重新授权。
|
|
72
|
+
|
|
73
|
+
### 4. 使用 API
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
# 查询空间配额
|
|
77
|
+
quota = client.get_quota()
|
|
78
|
+
print(f"已用: {quota.used / 1024**3:.1f} GB / 总计: {quota.total / 1024**3:.1f} GB")
|
|
79
|
+
|
|
80
|
+
# 上传文件 - 支持 Path / bytes / Generator
|
|
81
|
+
result = client.upload(Path("local_file.txt"))
|
|
82
|
+
result = client.upload(Path("data.bin"), file_to=Path("/backup/data.bin"))
|
|
83
|
+
result = client.upload(b"hello world", file_to=Path("/hello.txt"))
|
|
84
|
+
|
|
85
|
+
# 获取文件元信息
|
|
86
|
+
metas = client.get_file_metas([result.fs_id])
|
|
87
|
+
dlink = metas[0].dlink
|
|
88
|
+
|
|
89
|
+
# 下载文件 - file_to=None 返回流式迭代器,file_to=Path 写入文件
|
|
90
|
+
path = client.download(dlink, file_to=Path("downloaded.txt"))
|
|
91
|
+
|
|
92
|
+
# 流式下载(适合大文件或自定义处理)
|
|
93
|
+
for chunk in client.download(dlink):
|
|
94
|
+
process(chunk)
|
|
95
|
+
|
|
96
|
+
# 列出文件
|
|
97
|
+
items = client.get_file_list("/apps/your_app_name/")
|
|
98
|
+
for item in items:
|
|
99
|
+
print(f"{'[DIR]' if item.is_dir else ' '} {item.filename} ({item.size} bytes)")
|
|
100
|
+
|
|
101
|
+
# 删除文件
|
|
102
|
+
client.delete_files(["/apps/your_app_name/old_file.txt"])
|
|
103
|
+
|
|
104
|
+
# 使用完毕后关闭
|
|
105
|
+
client.close()
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
推荐使用上下文管理器自动关闭:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
with BaiduPanClient(client_id, client_secret, app_name) as client:
|
|
112
|
+
quota = client.get_quota()
|
|
113
|
+
# ...
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## API 参考
|
|
117
|
+
|
|
118
|
+
### BaiduPanClient
|
|
119
|
+
|
|
120
|
+
| 方法 | 说明 |
|
|
121
|
+
|------|------|
|
|
122
|
+
| `upload(file_from, file_to=None)` | 上传文件。`file_from` 支持 `Path` / `bytes` / `Generator[bytes]`;`file_to` 为远程路径 `Path`,默认使用文件名 |
|
|
123
|
+
| `download(dlink, file_to=None, chunk_size=1048576)` | 下载文件。`file_to=None` 返回字节迭代器;`file_to=Path` 写入本地文件 |
|
|
124
|
+
| `get_quota()` | 获取空间配额,返回 `ApiQuotaInfo` |
|
|
125
|
+
| `get_file_metas(fsids)` | 获取文件元信息,返回 `list[ApiFileMeta]` |
|
|
126
|
+
| `get_file_list(path)` | 列出目录文件,返回 `list[ApiFileListItem]` |
|
|
127
|
+
| `delete_files(file_paths)` | 批量删除文件 |
|
|
128
|
+
| `fetch_token(code)` | 通过授权码获取 Token |
|
|
129
|
+
| `refresh_token()` | 刷新 Token |
|
|
130
|
+
| `close()` | 关闭客户端 |
|
|
131
|
+
|
|
132
|
+
### 异常体系
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
BaiduPanError
|
|
136
|
+
├── BaiduPanNetworkError # 网络通信异常
|
|
137
|
+
├── BaiduPanAPIError # API 业务异常 (errno, errmsg, request_id)
|
|
138
|
+
└── TokenExpiredError # Token 过期或无效
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
所有异常均继承自 `BaiduPanError`,方便统一捕获。
|
|
142
|
+
|
|
143
|
+
### 特性
|
|
144
|
+
|
|
145
|
+
- **自动重试** — 网络请求自动重试(可配置次数),支持指数退避
|
|
146
|
+
- **Token 管理** — 自动持久化、自动刷新、文件权限限制
|
|
147
|
+
- **分片上传** — 大文件自动分片上传(4MB 分片)
|
|
148
|
+
- **流式下载** — 支持断点续传、指数重试
|
|
149
|
+
- **类型安全** — 完整类型标注,支持 PEP 561 (`py.typed`)
|
|
150
|
+
- **报错清晰** — 异常包含 `errno`、`errmsg`、`request_id`、`url` 等上下文
|
|
151
|
+
|
|
152
|
+
## 开发
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
# 安装开发依赖
|
|
156
|
+
pip install -e ".[dev]"
|
|
157
|
+
|
|
158
|
+
# 运行测试
|
|
159
|
+
python -m pytest -v
|
|
160
|
+
|
|
161
|
+
# 构建
|
|
162
|
+
pip install build twine
|
|
163
|
+
python -m build
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
bdds/__init__.py,sha256=SRKoBKAWmw5AoZ4x-oq9cevDyQzA-8gerIz2IGLCoUw,944
|
|
2
|
+
bdds/_http.py,sha256=42ZrEKfUxYyUjIpQa-JwXN9GUMSg_baf3gXKkUZ68M0,5855
|
|
3
|
+
bdds/client.py,sha256=9SLNRK7bMUl808_tLU6jtwl01HJXkbnVSOi6PrqrqHs,13095
|
|
4
|
+
bdds/download.py,sha256=DswF5FiPesluRpWYxQe-oUuewiB_mACDoX5glOXHyA0,3777
|
|
5
|
+
bdds/errors.py,sha256=yPozB5NPvtiFLXIQC1VqYznSO37qd9Er9TSXoZzXDwU,1000
|
|
6
|
+
bdds/models.py,sha256=1toQNjm5SKAj3T64dWkNhMwBmusIB2KgCJlrxNgUVUA,867
|
|
7
|
+
bdds/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
bdds/upload.py,sha256=-JiQZCjOCtZhDO3xbzpma3w0upxfrG6bzIsiWKcqC4Q,9638
|
|
9
|
+
bdds-0.1.0.dist-info/licenses/LICENSE,sha256=GAcztkuPxpRYGIXQOS-6xyuNzexyrrVi1VGd2r7wCjQ,1074
|
|
10
|
+
bdds-0.1.0.dist-info/METADATA,sha256=2gdJh71PVz88o1VubnU-r31IgaKam6SFz8uGM_tF72M,5142
|
|
11
|
+
bdds-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
12
|
+
bdds-0.1.0.dist-info/top_level.txt,sha256=YYJOdAVbaazGkHpjqiN-6_DYagn_RjA5WVS-v0otllU,5
|
|
13
|
+
bdds-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bdds contributors
|
|
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
|
+
bdds
|