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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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