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/__init__.py +39 -0
- abdds/_http.py +165 -0
- abdds/client.py +419 -0
- abdds/download.py +125 -0
- abdds/errors.py +38 -0
- abdds/models.py +58 -0
- abdds/py.typed +0 -0
- abdds/upload.py +368 -0
- abdds-0.1.0.dist-info/METADATA +193 -0
- abdds-0.1.0.dist-info/RECORD +12 -0
- abdds-0.1.0.dist-info/WHEEL +5 -0
- abdds-0.1.0.dist-info/top_level.txt +1 -0
abdds/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""abdds - 百度网盘异步 Python SDK (async-baidu-disk-sdk)
|
|
2
|
+
|
|
3
|
+
简单、有效、报错清晰的百度网盘异步 SDK。
|
|
4
|
+
|
|
5
|
+
Usage::
|
|
6
|
+
|
|
7
|
+
from abdds import AsyncBaiduPanClient
|
|
8
|
+
|
|
9
|
+
async with AsyncBaiduPanClient(client_id, client_secret, app_name) as client:
|
|
10
|
+
quota = await client.get_quota()
|
|
11
|
+
await client.upload(Path("file.txt"), file_to=Path("/docs/file.txt"))
|
|
12
|
+
await client.download(dlink, file_to=Path("output.txt"))
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
from .client import AsyncBaiduPanClient
|
|
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
|
+
"AsyncBaiduPanClient",
|
|
29
|
+
# Errors
|
|
30
|
+
"BaiduPanError",
|
|
31
|
+
"BaiduPanNetworkError",
|
|
32
|
+
"BaiduPanAPIError",
|
|
33
|
+
"TokenExpiredError",
|
|
34
|
+
# Models
|
|
35
|
+
"UploadResult",
|
|
36
|
+
"ApiFileMeta",
|
|
37
|
+
"ApiFileListItem",
|
|
38
|
+
"ApiQuotaInfo",
|
|
39
|
+
]
|
abdds/_http.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""HTTP 传输层 - 异步 Session 管理、请求封装、重试策略"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Awaitable, Callable
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .errors import BaiduPanAPIError, BaiduPanNetworkError, TokenExpiredError
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("abdds")
|
|
14
|
+
|
|
15
|
+
UserAgent = "netdisk;2.2.51.6;netdisk;10.0;PC;PC;Mac OS X 10.15.7;en-US"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _truncate_data(data: Any, max_len: int = 500) -> Any:
|
|
19
|
+
"""递归截断超长字符串和二进制数据,用于日志安全输出"""
|
|
20
|
+
if isinstance(data, dict):
|
|
21
|
+
return {k: _truncate_data(v, max_len) for k, v in data.items()}
|
|
22
|
+
if isinstance(data, (list, tuple, set)):
|
|
23
|
+
return [_truncate_data(i, max_len) for i in data]
|
|
24
|
+
if isinstance(data, (bytes, bytearray)):
|
|
25
|
+
return f"<Binary Data: {len(data)} bytes>"
|
|
26
|
+
if isinstance(data, str) and len(data) > max_len:
|
|
27
|
+
return f"{data[:max_len]}... [truncated]"
|
|
28
|
+
return data
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _HttpTransport:
|
|
32
|
+
"""封装 httpx.AsyncClient,提供自动 Token 注入、重试和错误处理"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
max_retries: int = 3,
|
|
37
|
+
timeout: tuple[int, int] = (100, 600),
|
|
38
|
+
) -> None:
|
|
39
|
+
self._max_retries = max_retries
|
|
40
|
+
self._timeout = timeout
|
|
41
|
+
self._client: httpx.AsyncClient | None = None
|
|
42
|
+
self._closed: bool = False
|
|
43
|
+
self._access_token: str | None = None
|
|
44
|
+
self._on_token_expired: Callable[[], Awaitable[str | None]] | None = None
|
|
45
|
+
|
|
46
|
+
def _init_client(self) -> httpx.AsyncClient:
|
|
47
|
+
transport = httpx.AsyncHTTPTransport(
|
|
48
|
+
retries=self._max_retries,
|
|
49
|
+
)
|
|
50
|
+
client = httpx.AsyncClient(
|
|
51
|
+
transport=transport,
|
|
52
|
+
timeout=httpx.Timeout(self._timeout[1], connect=self._timeout[0]),
|
|
53
|
+
headers={"User-Agent": UserAgent},
|
|
54
|
+
follow_redirects=True,
|
|
55
|
+
)
|
|
56
|
+
return client
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def client(self) -> httpx.AsyncClient:
|
|
60
|
+
"""获取或初始化 AsyncClient"""
|
|
61
|
+
if self._closed:
|
|
62
|
+
raise RuntimeError("Transport is closed, cannot make requests")
|
|
63
|
+
if self._client is None:
|
|
64
|
+
self._client = self._init_client()
|
|
65
|
+
return self._client
|
|
66
|
+
|
|
67
|
+
def set_access_token(self, token: str | None) -> None:
|
|
68
|
+
self._access_token = token
|
|
69
|
+
|
|
70
|
+
def set_token_expired_callback(
|
|
71
|
+
self, callback: Callable[[], Awaitable[str | None]]
|
|
72
|
+
) -> None:
|
|
73
|
+
"""设置 Token 过期时的异步刷新回调,回调应返回新的 access_token"""
|
|
74
|
+
self._on_token_expired = callback
|
|
75
|
+
|
|
76
|
+
async def request(
|
|
77
|
+
self, method: str, url: str, **kwargs: Any
|
|
78
|
+
) -> dict | httpx.Response:
|
|
79
|
+
"""
|
|
80
|
+
核心异步请求方法:
|
|
81
|
+
1. 自动注入 access_token
|
|
82
|
+
2. 处理网络层错误 (由 httpx transport retries 处理重试)
|
|
83
|
+
3. 处理业务层错误 (解析 errno)
|
|
84
|
+
4. Token 失效时自动刷新并重试一次
|
|
85
|
+
"""
|
|
86
|
+
logger.debug("method:%s url:%s kwargs:%s", method, url, _truncate_data(kwargs))
|
|
87
|
+
|
|
88
|
+
if self._closed:
|
|
89
|
+
raise RuntimeError("Transport is closed, cannot make requests")
|
|
90
|
+
|
|
91
|
+
if not self._access_token:
|
|
92
|
+
raise TokenExpiredError("Access token not initialized, please login first")
|
|
93
|
+
|
|
94
|
+
params = kwargs.pop("params", {})
|
|
95
|
+
params["access_token"] = self._access_token
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
response = await self.client.request(method, url, params=params, **kwargs)
|
|
99
|
+
response.raise_for_status()
|
|
100
|
+
|
|
101
|
+
# 流式响应直接返回
|
|
102
|
+
if kwargs.get("stream"):
|
|
103
|
+
return response # type: ignore[return-value]
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
data = response.json()
|
|
107
|
+
except (json.JSONDecodeError, ValueError):
|
|
108
|
+
raise BaiduPanAPIError(-1, "Invalid JSON response")
|
|
109
|
+
logger.debug("method:%s url:%s response:%s", method, url, _truncate_data(data))
|
|
110
|
+
|
|
111
|
+
except httpx.HTTPStatusError as e:
|
|
112
|
+
raise BaiduPanNetworkError(
|
|
113
|
+
f"HTTP {e.response.status_code}: {e}", url=url,
|
|
114
|
+
status_code=e.response.status_code,
|
|
115
|
+
) from e
|
|
116
|
+
except httpx.RequestError as e:
|
|
117
|
+
raise BaiduPanNetworkError(
|
|
118
|
+
f"Request failed: {e}", url=url
|
|
119
|
+
) from e
|
|
120
|
+
|
|
121
|
+
# 业务错误检查
|
|
122
|
+
errno = data.get("errno", 0)
|
|
123
|
+
if errno != 0:
|
|
124
|
+
# Token 失效 (errno: -6 或 11)
|
|
125
|
+
if errno in (-6, 11) and self._on_token_expired:
|
|
126
|
+
logger.warning("Token expired during request, refreshing...")
|
|
127
|
+
new_token = await self._on_token_expired()
|
|
128
|
+
if new_token:
|
|
129
|
+
params["access_token"] = new_token
|
|
130
|
+
try:
|
|
131
|
+
response = await self.client.request(
|
|
132
|
+
method, url, params=params, **kwargs
|
|
133
|
+
)
|
|
134
|
+
response.raise_for_status()
|
|
135
|
+
data = response.json()
|
|
136
|
+
except httpx.RequestError as e:
|
|
137
|
+
raise BaiduPanNetworkError(
|
|
138
|
+
f"Retry after token refresh failed: {e}", url=url
|
|
139
|
+
) from e
|
|
140
|
+
except (json.JSONDecodeError, ValueError):
|
|
141
|
+
raise BaiduPanAPIError(-1, "Invalid JSON response")
|
|
142
|
+
|
|
143
|
+
if data.get("errno", 0) == 0:
|
|
144
|
+
return data
|
|
145
|
+
|
|
146
|
+
# 刷新后仍失败
|
|
147
|
+
raise BaiduPanAPIError(
|
|
148
|
+
data.get("errno", errno),
|
|
149
|
+
data.get("errmsg", "Token refresh failed"),
|
|
150
|
+
request_id=data.get("request_id", ""),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
raise BaiduPanAPIError(
|
|
154
|
+
errno,
|
|
155
|
+
data.get("errmsg", "Unknown error"),
|
|
156
|
+
request_id=data.get("request_id", ""),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return data
|
|
160
|
+
|
|
161
|
+
async def close(self) -> None:
|
|
162
|
+
self._closed = True
|
|
163
|
+
if self._client is not None:
|
|
164
|
+
await self._client.aclose()
|
|
165
|
+
self._client = None
|
abdds/client.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""AsyncBaiduPanClient - 百度网盘异步 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 AsyncGenerator, Awaitable, Callable
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
import aiofiles
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ._http import _HttpTransport
|
|
17
|
+
from .download import download as _download
|
|
18
|
+
from .errors import BaiduPanNetworkError, TokenExpiredError
|
|
19
|
+
from .models import ApiFileListItem, ApiFileMeta, ApiQuotaInfo, UploadResult
|
|
20
|
+
from .upload import upload as _upload
|
|
21
|
+
from .upload import UploadSource, _is_async_generator, _is_generator
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("abdds")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AsyncBaiduPanClient:
|
|
27
|
+
"""
|
|
28
|
+
百度网盘异步 Python SDK 客户端
|
|
29
|
+
|
|
30
|
+
具备自动重试、Token 管理、流式下载和分片上传。
|
|
31
|
+
|
|
32
|
+
Usage::
|
|
33
|
+
|
|
34
|
+
async with AsyncBaiduPanClient(client_id, client_secret, app_name) as client:
|
|
35
|
+
if not client.access_token:
|
|
36
|
+
print(client.auth_url)
|
|
37
|
+
code = input("Enter code: ")
|
|
38
|
+
await client.fetch_token(code)
|
|
39
|
+
|
|
40
|
+
quota = await client.get_quota()
|
|
41
|
+
result = await client.upload(Path("local_file.txt"))
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
HOST_OAUTH = "https://openapi.baidu.com/oauth/2.0"
|
|
45
|
+
HOST_PAN = "https://pan.baidu.com"
|
|
46
|
+
|
|
47
|
+
URL_QUOTA = f"{HOST_PAN}/api/quota"
|
|
48
|
+
URL_FILE = f"{HOST_PAN}/rest/2.0/xpan/file"
|
|
49
|
+
URL_MULTIMEDIA = f"{HOST_PAN}/rest/2.0/xpan/multimedia"
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
client_id: str,
|
|
54
|
+
client_secret: str,
|
|
55
|
+
app_name: str,
|
|
56
|
+
config_dir: Path | None = None,
|
|
57
|
+
max_retries: int = 3,
|
|
58
|
+
timeout: tuple[int, int] | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""
|
|
61
|
+
初始化百度网盘异步客户端
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
client_id: 百度开放平台应用的 API Key
|
|
65
|
+
client_secret: 百度开放平台应用的 Secret Key
|
|
66
|
+
app_name: 应用名称
|
|
67
|
+
config_dir: Token 存储目录,默认 ~/.baidupan
|
|
68
|
+
max_retries: 请求重试次数,默认 3
|
|
69
|
+
timeout: 请求超时 (connect, read),默认 (100, 600)
|
|
70
|
+
"""
|
|
71
|
+
self._client_id = client_id
|
|
72
|
+
self._client_secret = client_secret
|
|
73
|
+
self.app_name = app_name
|
|
74
|
+
self.pan_dir_base = f"/apps/{app_name}"
|
|
75
|
+
|
|
76
|
+
if config_dir is None:
|
|
77
|
+
config_dir = Path.home() / ".baidupan"
|
|
78
|
+
self.config_dir = config_dir
|
|
79
|
+
self._token_file = self.config_dir / f"access_token_{client_id}.json"
|
|
80
|
+
|
|
81
|
+
self._token_data: dict = {}
|
|
82
|
+
self._access_token: str | None = None
|
|
83
|
+
self._refresh_token: str | None = None
|
|
84
|
+
|
|
85
|
+
# 初始化异步 HTTP 传输层
|
|
86
|
+
self._transport = _HttpTransport(
|
|
87
|
+
max_retries=max_retries,
|
|
88
|
+
timeout=timeout or (100, 600),
|
|
89
|
+
)
|
|
90
|
+
self._transport.set_token_expired_callback(self._handle_token_expired)
|
|
91
|
+
|
|
92
|
+
# 注意: _load_token 是异步方法,不能在 __init__ 中直接调用
|
|
93
|
+
# 需要用户在初始化后手动调用 await client._load_token()
|
|
94
|
+
# 或者使用 create 类方法
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
async def create(
|
|
98
|
+
cls,
|
|
99
|
+
client_id: str,
|
|
100
|
+
client_secret: str,
|
|
101
|
+
app_name: str,
|
|
102
|
+
config_dir: Path | None = None,
|
|
103
|
+
max_retries: int = 3,
|
|
104
|
+
timeout: tuple[int, int] | None = None,
|
|
105
|
+
) -> AsyncBaiduPanClient:
|
|
106
|
+
"""异步工厂方法,初始化客户端并自动加载已保存的 Token"""
|
|
107
|
+
instance = cls(client_id, client_secret, app_name, config_dir, max_retries, timeout)
|
|
108
|
+
await instance._load_token()
|
|
109
|
+
return instance
|
|
110
|
+
|
|
111
|
+
# ---- 上下文管理器 ----
|
|
112
|
+
|
|
113
|
+
async def close(self) -> None:
|
|
114
|
+
"""关闭客户端,释放资源"""
|
|
115
|
+
await self._transport.close()
|
|
116
|
+
|
|
117
|
+
async def __aenter__(self) -> AsyncBaiduPanClient:
|
|
118
|
+
await self._load_token()
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
async def __aexit__(
|
|
122
|
+
self, exc_type: type[BaseException] | None,
|
|
123
|
+
exc_val: BaseException | None,
|
|
124
|
+
exc_tb: object,
|
|
125
|
+
) -> bool | None:
|
|
126
|
+
await self.close()
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
# ---- Token 管理 ----
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def access_token(self) -> str | None:
|
|
133
|
+
"""当前 access_token(只读)"""
|
|
134
|
+
return self._access_token
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def auth_url(self) -> str:
|
|
138
|
+
"""生成用户授权 URL"""
|
|
139
|
+
return (
|
|
140
|
+
f"{self.HOST_OAUTH}/authorize?"
|
|
141
|
+
f"response_type=code&client_id={self._client_id}&redirect_uri=oob&scope=basic,netdisk"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
async def fetch_token(self, code: str) -> None:
|
|
145
|
+
"""
|
|
146
|
+
通过授权码获取 Access Token
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
code: 用户授权后获得的授权码
|
|
150
|
+
"""
|
|
151
|
+
url = f"{self.HOST_OAUTH}/token"
|
|
152
|
+
params = {
|
|
153
|
+
"grant_type": "authorization_code",
|
|
154
|
+
"code": code,
|
|
155
|
+
"client_id": self._client_id,
|
|
156
|
+
"client_secret": self._client_secret,
|
|
157
|
+
"redirect_uri": "oob",
|
|
158
|
+
}
|
|
159
|
+
try:
|
|
160
|
+
resp = await self._transport.client.get(url, params=params, timeout=10)
|
|
161
|
+
resp.raise_for_status()
|
|
162
|
+
result = resp.json()
|
|
163
|
+
except httpx.RequestError as e:
|
|
164
|
+
raise BaiduPanNetworkError(f"Fetch token failed: {e}") from e
|
|
165
|
+
|
|
166
|
+
# 检查 API 层面错误
|
|
167
|
+
if "error" in result:
|
|
168
|
+
raise TokenExpiredError(
|
|
169
|
+
f"Fetch token failed: {result.get('error_description', result['error'])}"
|
|
170
|
+
)
|
|
171
|
+
await self._save_token(result)
|
|
172
|
+
|
|
173
|
+
async def refresh_token(self) -> str:
|
|
174
|
+
"""
|
|
175
|
+
刷新 Access Token
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
新的 access_token
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
TokenExpiredError: 无 refresh_token 可用
|
|
182
|
+
"""
|
|
183
|
+
if not self._refresh_token:
|
|
184
|
+
raise TokenExpiredError("No refresh token available, please re-login")
|
|
185
|
+
|
|
186
|
+
url = f"{self.HOST_OAUTH}/token"
|
|
187
|
+
params = {
|
|
188
|
+
"grant_type": "refresh_token",
|
|
189
|
+
"refresh_token": self._refresh_token,
|
|
190
|
+
"client_id": self._client_id,
|
|
191
|
+
"client_secret": self._client_secret,
|
|
192
|
+
}
|
|
193
|
+
try:
|
|
194
|
+
resp = await self._transport.client.get(url, params=params, timeout=10)
|
|
195
|
+
resp.raise_for_status()
|
|
196
|
+
result = resp.json()
|
|
197
|
+
except httpx.RequestError as e:
|
|
198
|
+
raise BaiduPanNetworkError(f"Refresh token failed: {e}") from e
|
|
199
|
+
|
|
200
|
+
# 检查 API 层面错误
|
|
201
|
+
if "error" in result:
|
|
202
|
+
raise TokenExpiredError(
|
|
203
|
+
f"Refresh token failed: {result.get('error_description', result['error'])}"
|
|
204
|
+
)
|
|
205
|
+
await self._save_token(result)
|
|
206
|
+
return self._access_token # type: ignore[return-value]
|
|
207
|
+
|
|
208
|
+
async def _handle_token_expired(self) -> str | None:
|
|
209
|
+
"""Token 过期回调,供 _HttpTransport 调用"""
|
|
210
|
+
try:
|
|
211
|
+
return await self.refresh_token()
|
|
212
|
+
except (TokenExpiredError, BaiduPanNetworkError):
|
|
213
|
+
logger.error("Token refresh failed in auto-retry")
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
async def _load_token(self) -> None:
|
|
217
|
+
"""从本地文件异步加载 Token"""
|
|
218
|
+
if not self._token_file.exists():
|
|
219
|
+
return
|
|
220
|
+
try:
|
|
221
|
+
async with aiofiles.open(self._token_file, encoding="utf-8") as f:
|
|
222
|
+
content = await f.read()
|
|
223
|
+
data = json.loads(content)
|
|
224
|
+
self._token_data = data
|
|
225
|
+
self._access_token = data.get("access_token")
|
|
226
|
+
self._refresh_token = data.get("refresh_token")
|
|
227
|
+
self._transport.set_access_token(self._access_token)
|
|
228
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
229
|
+
logger.error("Failed to load token: %s", e)
|
|
230
|
+
|
|
231
|
+
async def _save_token(self, data: dict) -> None:
|
|
232
|
+
"""异步持久化保存 Token"""
|
|
233
|
+
if "access_token" not in data:
|
|
234
|
+
error_desc = data.get("error_description", data.get("Error", "Unknown error"))
|
|
235
|
+
raise TokenExpiredError(f"Token response missing access_token: {error_desc}")
|
|
236
|
+
|
|
237
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
data["update_at"] = int(time.time())
|
|
239
|
+
self._token_data = data
|
|
240
|
+
self._access_token = data["access_token"]
|
|
241
|
+
self._refresh_token = data.get("refresh_token")
|
|
242
|
+
self._transport.set_access_token(self._access_token)
|
|
243
|
+
|
|
244
|
+
async with aiofiles.open(self._token_file, "w", encoding="utf-8") as f:
|
|
245
|
+
await f.write(json.dumps(data, indent=2))
|
|
246
|
+
# 限制文件权限为仅拥有者可读写
|
|
247
|
+
try:
|
|
248
|
+
self._token_file.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
249
|
+
except OSError:
|
|
250
|
+
pass # Windows 不完全支持 chmod
|
|
251
|
+
|
|
252
|
+
logger.info("Access token saved")
|
|
253
|
+
|
|
254
|
+
# ---- 业务 API ----
|
|
255
|
+
|
|
256
|
+
async def get_quota(self) -> ApiQuotaInfo:
|
|
257
|
+
"""
|
|
258
|
+
获取网盘空间配额
|
|
259
|
+
|
|
260
|
+
https://pan.baidu.com/union/doc/Cksg0s9ic
|
|
261
|
+
"""
|
|
262
|
+
data = await self._transport.request("GET", self.URL_QUOTA)
|
|
263
|
+
return ApiQuotaInfo(total=data.get("total", 0), used=data.get("used", 0))
|
|
264
|
+
|
|
265
|
+
async def get_file_metas(self, fsids: list[int]) -> list[ApiFileMeta]:
|
|
266
|
+
"""
|
|
267
|
+
获取文件元信息
|
|
268
|
+
|
|
269
|
+
https://pan.baidu.com/union/doc/Fksg0sbcm
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
fsids: 文件 ID 列表
|
|
273
|
+
"""
|
|
274
|
+
if not fsids:
|
|
275
|
+
return []
|
|
276
|
+
|
|
277
|
+
params = {"method": "filemetas", "fsids": json.dumps(fsids), "dlink": 1}
|
|
278
|
+
data = await self._transport.request("GET", self.URL_MULTIMEDIA, params=params)
|
|
279
|
+
items = data.get("list", [])
|
|
280
|
+
|
|
281
|
+
return [
|
|
282
|
+
ApiFileMeta(
|
|
283
|
+
fs_id=item["fs_id"],
|
|
284
|
+
filename=item["filename"],
|
|
285
|
+
path=item["path"],
|
|
286
|
+
size=item["size"],
|
|
287
|
+
md5=item.get("md5", ""),
|
|
288
|
+
dlink=item.get("dlink", ""),
|
|
289
|
+
)
|
|
290
|
+
for item in items
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
async def get_file_list(self, path: str) -> list[ApiFileListItem]:
|
|
294
|
+
"""
|
|
295
|
+
获取指定目录下的文件列表
|
|
296
|
+
|
|
297
|
+
https://pan.baidu.com/union/doc/nksg0sat9
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
path: 目录路径
|
|
301
|
+
"""
|
|
302
|
+
params = {"method": "list", "dir": quote(path)}
|
|
303
|
+
data = await self._transport.request("GET", self.URL_FILE, params=params)
|
|
304
|
+
items = data.get("list", [])
|
|
305
|
+
|
|
306
|
+
return [
|
|
307
|
+
ApiFileListItem(
|
|
308
|
+
fs_id=item["fs_id"],
|
|
309
|
+
filename=item["server_filename"],
|
|
310
|
+
path=item["path"],
|
|
311
|
+
size=item["size"],
|
|
312
|
+
md5=item.get("md5", ""),
|
|
313
|
+
is_dir=item.get("isdir", 0),
|
|
314
|
+
)
|
|
315
|
+
for item in items
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
async def delete_files(self, file_paths: list[str]) -> None:
|
|
319
|
+
"""
|
|
320
|
+
批量删除文件
|
|
321
|
+
|
|
322
|
+
https://pan.baidu.com/union/doc/mksg0s9l4
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
file_paths: 要删除的文件路径列表
|
|
326
|
+
"""
|
|
327
|
+
if not file_paths:
|
|
328
|
+
return
|
|
329
|
+
params = {"method": "filemanager", "opera": "delete"}
|
|
330
|
+
data = {"async": 2, "filelist": json.dumps(file_paths)}
|
|
331
|
+
await self._transport.request("POST", self.URL_FILE, params=params, data=data)
|
|
332
|
+
logger.info("Deleted %d file(s)", len(file_paths))
|
|
333
|
+
|
|
334
|
+
# ---- 上传 ----
|
|
335
|
+
|
|
336
|
+
async def upload(
|
|
337
|
+
self,
|
|
338
|
+
file_from: UploadSource,
|
|
339
|
+
file_to: Path | None = None,
|
|
340
|
+
) -> UploadResult:
|
|
341
|
+
"""
|
|
342
|
+
异步上传数据到百度网盘
|
|
343
|
+
|
|
344
|
+
- 小文件 (<=4MB): 单步上传
|
|
345
|
+
- 大文件 (>4MB): 分片上传
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
file_from: 数据来源,支持 Path / bytes / Generator[bytes] / AsyncGenerator[bytes]
|
|
349
|
+
file_to: 远程路径 (Path),拼接在 /apps/{app_name} 之后。
|
|
350
|
+
默认使用 file_from 的文件名 (Path 时) 或 "upload"
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
UploadResult: 上传结果
|
|
354
|
+
|
|
355
|
+
Raises:
|
|
356
|
+
TypeError: file_from 或 file_to 类型不正确
|
|
357
|
+
"""
|
|
358
|
+
# 类型校验
|
|
359
|
+
if (
|
|
360
|
+
not isinstance(file_from, (Path, bytes))
|
|
361
|
+
and not _is_generator(file_from)
|
|
362
|
+
and not _is_async_generator(file_from)
|
|
363
|
+
):
|
|
364
|
+
raise TypeError(
|
|
365
|
+
f"file_from must be Path, bytes, Generator[bytes], or AsyncGenerator[bytes], "
|
|
366
|
+
f"got {type(file_from).__name__}"
|
|
367
|
+
)
|
|
368
|
+
if file_to is not None and not isinstance(file_to, Path):
|
|
369
|
+
raise TypeError(
|
|
370
|
+
f"file_to must be Path or None, got {type(file_to).__name__}"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if isinstance(file_from, Path):
|
|
374
|
+
default_name = file_from.name
|
|
375
|
+
else:
|
|
376
|
+
default_name = "upload"
|
|
377
|
+
|
|
378
|
+
if file_to is None:
|
|
379
|
+
remote_path = PurePosixPath(f"/{default_name}")
|
|
380
|
+
else:
|
|
381
|
+
# 将 Path 转为 PurePosixPath 以确保 POSIX 风格
|
|
382
|
+
remote_path = PurePosixPath(file_to.as_posix())
|
|
383
|
+
if not remote_path.is_absolute():
|
|
384
|
+
remote_path = PurePosixPath(f"/{remote_path}")
|
|
385
|
+
|
|
386
|
+
return await _upload(self._transport, file_from, remote_path, self.pan_dir_base)
|
|
387
|
+
|
|
388
|
+
# ---- 下载 ----
|
|
389
|
+
|
|
390
|
+
async def download(
|
|
391
|
+
self,
|
|
392
|
+
dlink: str,
|
|
393
|
+
file_to: Path | None = None,
|
|
394
|
+
chunk_size: int = 1024 * 1024,
|
|
395
|
+
) -> AsyncGenerator[bytes, None] | Path:
|
|
396
|
+
"""
|
|
397
|
+
异步下载文件
|
|
398
|
+
|
|
399
|
+
- file_to=None: 返回异步字节迭代器 (流式下载)
|
|
400
|
+
- file_to=Path: 下载到本地文件,返回 Path
|
|
401
|
+
|
|
402
|
+
https://pan.baidu.com/union/doc/pkuo3snyp
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
dlink: 下载链接 (通过 get_file_metas 获取)
|
|
406
|
+
file_to: 本地保存路径 (Path),为 None 时返回迭代器
|
|
407
|
+
chunk_size: 分块大小,默认 1MB
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
AsyncGenerator[bytes] 当 file_to=None,Path 当 file_to 指定路径
|
|
411
|
+
|
|
412
|
+
Raises:
|
|
413
|
+
TypeError: file_to 类型不正确
|
|
414
|
+
"""
|
|
415
|
+
if file_to is not None and not isinstance(file_to, Path):
|
|
416
|
+
raise TypeError(
|
|
417
|
+
f"file_to must be Path or None, got {type(file_to).__name__}"
|
|
418
|
+
)
|
|
419
|
+
return await _download(self._transport, dlink, file_to, chunk_size)
|