pansh 3.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.
pansh/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """pansh package."""
2
+
3
+ from .version import __version__
4
+
5
+ __all__ = ["__version__"]
pansh/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Module entry point for ``python -m pansh``."""
2
+
3
+ from .main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
pansh/api.py ADDED
@@ -0,0 +1,477 @@
1
+ """Async AnyShare API wrapper used by the CLI and shell."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import re
8
+ import time
9
+ from typing import Any, AsyncIterator
10
+
11
+ from . import auth, network
12
+ from .models import DirEntry, FileMetaData, LinkInfo, QuotaInfo, ResourceInfo, RevisionInfo, SearchResult
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _next_compact_duplicate_name(name: str, existing_names: list[str]) -> str:
18
+ if name not in existing_names:
19
+ return name
20
+ stem, dot, suffix = name.partition(".")
21
+ tail = f".{suffix}" if dot else ""
22
+ pattern = re.compile(rf"^{re.escape(stem)}(?: ?\((\d+)\))?{re.escape(tail)}$")
23
+ max_index = 1
24
+ for existing in existing_names:
25
+ match = pattern.match(existing)
26
+ if not match:
27
+ continue
28
+ if match.group(1):
29
+ max_index = max(max_index, int(match.group(1)))
30
+ elif existing == name:
31
+ max_index = max(max_index, 1)
32
+ return f"{stem}({max_index + 1}){tail}"
33
+
34
+
35
+ def _find_existing_upload_entry(files: list[DirEntry], name: str) -> DirEntry | None:
36
+ exact = next((item for item in files if item.name == name), None)
37
+ if exact is not None:
38
+ return exact
39
+ stem, dot, suffix = name.partition(".")
40
+ tail = f".{suffix}" if dot else ""
41
+ pattern = re.compile(rf"^{re.escape(stem)}(?: ?\((\d+)\))?{re.escape(tail)}$")
42
+ candidates: list[tuple[int, int, DirEntry]] = []
43
+ for item in files:
44
+ match = pattern.match(item.name)
45
+ if not match:
46
+ continue
47
+ number = int(match.group(1) or 0)
48
+ candidates.append((number, item.modified, item))
49
+ if not candidates:
50
+ return None
51
+ candidates.sort(key=lambda row: (row[0], row[1]))
52
+ return candidates[-1][2]
53
+
54
+
55
+ async def _normalize_uploaded_name(
56
+ manager: "AsyncApiManager",
57
+ parent_dir_id: str,
58
+ *,
59
+ source_name: str,
60
+ previous_entry: DirEntry | None,
61
+ ) -> None:
62
+ if previous_entry is None or previous_entry.name == source_name:
63
+ return
64
+ for _ in range(5):
65
+ _, files = await manager.list_dir(parent_dir_id, by="name")
66
+ if any(item.name == source_name for item in files):
67
+ return
68
+ current = _find_existing_upload_entry(files, source_name)
69
+ if current is not None:
70
+ await manager.rename_file(current.docid, source_name)
71
+ return
72
+ await asyncio.sleep(0.2)
73
+
74
+
75
+ class ApiManagerException(Exception):
76
+ pass
77
+
78
+
79
+ class WrongPasswordException(ApiManagerException):
80
+ pass
81
+
82
+
83
+ class InvalidRootException(ApiManagerException):
84
+ pass
85
+
86
+
87
+ class NeedReviewException(ApiManagerException):
88
+ pass
89
+
90
+
91
+ class MoveToChildDirectoryException(ApiManagerException):
92
+ pass
93
+
94
+
95
+ class AsyncApiManager:
96
+ def __init__(
97
+ self,
98
+ host: str,
99
+ username: str,
100
+ password: str | None,
101
+ pubkey: str,
102
+ *,
103
+ encrypted: str | None = None,
104
+ cached_token: str | None = None,
105
+ cached_expire: float | None = None,
106
+ ) -> None:
107
+ self.host = host
108
+ self.base_url = f"https://{host}:443/api/efast/v1"
109
+ self._pubkey = pubkey
110
+ self._password = password
111
+ self._username = username
112
+ self._encrypted = encrypted
113
+ self._tokenid = cached_token or ""
114
+ self._expires = cached_expire or 0.0
115
+ self._client: network.httpx.AsyncClient | None = None
116
+
117
+ @property
118
+ def client(self) -> network.httpx.AsyncClient:
119
+ if self._client is None:
120
+ self._client = network.create_async_client()
121
+ return self._client
122
+
123
+ async def initialize(self) -> None:
124
+ await self._check_token(use_request=True)
125
+
126
+ async def close(self) -> None:
127
+ if self._client is not None:
128
+ await self._client.aclose()
129
+ self._client = None
130
+
131
+ def _url(self, path: str) -> str:
132
+ if not path.startswith("/"):
133
+ path = "/" + path
134
+ return self.base_url + path
135
+
136
+ def _encrypt_password(self) -> str:
137
+ if self._encrypted is None and self._password is not None:
138
+ self._encrypted = auth.rsa_encrypt(self._password, self._pubkey)
139
+ return self._encrypted or ""
140
+
141
+ async def _update_token(self) -> None:
142
+ started = time.perf_counter()
143
+ try:
144
+ self._tokenid = await asyncio.to_thread(
145
+ auth.get_access_token,
146
+ f"https://{self.host}:443/",
147
+ self._username,
148
+ self._encrypt_password(),
149
+ )
150
+ self._expires = time.time() + 3600
151
+ except network.ApiException as exc:
152
+ if exc.err and exc.err.get("code") == 401001003:
153
+ raise WrongPasswordException(str(exc)) from exc
154
+ raise
155
+ finally:
156
+ logger.debug("token refresh took %.3fs", time.perf_counter() - started)
157
+
158
+ async def _check_token(self, use_request: bool = False) -> None:
159
+ if time.time() < (self._expires - 60) and self._tokenid:
160
+ if not use_request:
161
+ return
162
+ if not self._tokenid or time.time() >= (self._expires - 60):
163
+ await self._update_token()
164
+ return
165
+ if use_request:
166
+ started = time.perf_counter()
167
+ try:
168
+ await self.get_entrydoc()
169
+ except network.ApiException as exc:
170
+ if exc.err and exc.err.get("code") == 401001001:
171
+ await self._update_token()
172
+ else:
173
+ raise
174
+ finally:
175
+ logger.debug("first request took %.3fs", time.perf_counter() - started)
176
+
177
+ async def _post(self, path: str, body: dict[str, Any]) -> Any:
178
+ await self._check_token()
179
+ return await network.async_post_json(
180
+ self._url(path),
181
+ body,
182
+ tokenid=self._tokenid,
183
+ client=self.client,
184
+ )
185
+
186
+ async def _get(self, path: str) -> Any:
187
+ await self._check_token()
188
+ return await network.async_get_json(
189
+ self._url(path),
190
+ tokenid=self._tokenid,
191
+ client=self.client,
192
+ )
193
+
194
+ async def get_entrydoc(self) -> list[dict]:
195
+ data = await self._get("/entry-doc-lib?type=user_doc_lib&sort=doc_lib_name&direction=asc")
196
+ return data or []
197
+
198
+ async def get_current_user(self) -> dict[str, Any]:
199
+ return await self._post("/../../eacp/v1/user/get", {})
200
+
201
+ async def get_quota(self) -> QuotaInfo:
202
+ data = await self._get("/quota/user")
203
+ return QuotaInfo.model_validate(data or {})
204
+
205
+ async def get_resource_info_by_path(self, path: str) -> ResourceInfo | None:
206
+ path = path.strip("/")
207
+ if not path:
208
+ return None
209
+ try:
210
+ data = await self._post("/file/getinfobypath", {"namepath": path})
211
+ except network.ApiException as exc:
212
+ if exc.err and exc.err.get("code") in (404006, 403024, 404002006, 400002015):
213
+ return None
214
+ raise
215
+ return ResourceInfo.model_validate(data) if data else None
216
+
217
+ async def get_resource_id(self, path: str) -> str | None:
218
+ info = await self.get_resource_info_by_path(path)
219
+ return info.docid if info else None
220
+
221
+ async def get_resource_path(self, docid: str) -> str:
222
+ data = await self._post("/file/convertpath", {"docid": docid})
223
+ return data["namepath"]
224
+
225
+ async def get_file_meta(self, file_id: str) -> FileMetaData:
226
+ data = await self._post("/file/metadata", {"docid": file_id})
227
+ return FileMetaData.model_validate(data)
228
+
229
+ async def list_dir(
230
+ self,
231
+ dir_id: str,
232
+ *,
233
+ by: str | None = None,
234
+ sort: str | None = None,
235
+ with_attr: bool = False,
236
+ ) -> tuple[list[DirEntry], list[DirEntry]]:
237
+ payload: dict[str, Any] = {"docid": dir_id, "attr": bool(with_attr)}
238
+ if by:
239
+ payload["by"] = by
240
+ if sort:
241
+ payload["sort"] = sort
242
+ data = await self._post("/dir/list", payload)
243
+ data = data or {}
244
+ dirs = [DirEntry.from_dict(item, is_dir=True) for item in data.get("dirs", [])]
245
+ files = [DirEntry.from_dict(item, is_dir=False) for item in data.get("files", [])]
246
+ return dirs, files
247
+
248
+ async def create_dir(self, parent_dir_id: str, name: str) -> str:
249
+ data = await self._post("/dir/create", {"docid": parent_dir_id, "name": name})
250
+ return data["docid"]
251
+
252
+ async def create_dirs(self, parent_dir_id: str, dirs: str) -> str:
253
+ data = await self._post("/dir/createmultileveldir", {"docid": parent_dir_id, "path": dirs})
254
+ return data["docid"]
255
+
256
+ async def create_dirs_by_path(self, dirs: str) -> str:
257
+ parts = [chunk for chunk in dirs.strip("/").split("/") if chunk]
258
+ if not parts:
259
+ raise InvalidRootException("empty remote path")
260
+ root_id = await self.get_resource_id(parts[0])
261
+ if root_id is None:
262
+ raise InvalidRootException("root dir does not exist")
263
+ if len(parts) == 1:
264
+ return root_id
265
+ return await self.create_dirs(root_id, "/".join(parts[1:]))
266
+
267
+ async def delete_file(self, file_id: str) -> None:
268
+ await self._post("/file/delete", {"docid": file_id})
269
+
270
+ async def delete_dir(self, dir_id: str) -> None:
271
+ await self._post("/dir/delete", {"docid": dir_id})
272
+
273
+ async def rename_file(self, file_id: str, new_name: str, *, rename_on_dup: bool = False) -> str | None:
274
+ data = await self._post(
275
+ "/file/rename",
276
+ {"docid": file_id, "name": new_name, "ondup": 2 if rename_on_dup else 1},
277
+ )
278
+ return data["name"] if rename_on_dup else None
279
+
280
+ async def move_file(
281
+ self,
282
+ file_id: str,
283
+ dest_dir_id: str,
284
+ *,
285
+ rename_on_dup: bool = False,
286
+ overwrite_on_dup: bool = False,
287
+ ) -> str | tuple[str, str]:
288
+ ondup = 2 if rename_on_dup else (3 if overwrite_on_dup else 1)
289
+ try:
290
+ data = await self._post("/file/move", {"docid": file_id, "destparent": dest_dir_id, "ondup": ondup})
291
+ except network.ApiException as exc:
292
+ if exc.err and exc.err.get("errcode") == 403019:
293
+ raise MoveToChildDirectoryException() from exc
294
+ raise
295
+ if rename_on_dup:
296
+ return data["docid"], data["name"]
297
+ return data["docid"]
298
+
299
+ async def copy_file(
300
+ self,
301
+ file_id: str,
302
+ dest_dir_id: str,
303
+ *,
304
+ rename_on_dup: bool = False,
305
+ overwrite_on_dup: bool = False,
306
+ ) -> str | tuple[str, str]:
307
+ ondup = 2 if rename_on_dup else (3 if overwrite_on_dup else 1)
308
+ try:
309
+ data = await self._post("/file/copy", {"docid": file_id, "destparent": dest_dir_id, "ondup": ondup})
310
+ except network.ApiException as exc:
311
+ if exc.err and exc.err.get("errcode") == 403019:
312
+ raise MoveToChildDirectoryException() from exc
313
+ raise
314
+ if rename_on_dup:
315
+ return data["docid"], data["name"]
316
+ return data["docid"]
317
+
318
+ async def get_download_url(self, file_id: str) -> tuple[str, int]:
319
+ data = await self._post("/file/osdownload", {"docid": file_id, "authtype": "QUERY_STRING"})
320
+ return data["authrequest"][1], int(data.get("length", 0))
321
+
322
+ async def download_file_stream(self, file_id: str, *, resume_from: int = 0) -> AsyncIterator[bytes]:
323
+ url, _ = await self.get_download_url(file_id)
324
+ headers = {"Range": f"bytes={resume_from}-"} if resume_from > 0 else {}
325
+ async for chunk in network.async_stream_download(url, headers=headers, client=self.client):
326
+ yield chunk
327
+
328
+ async def upload_file(
329
+ self,
330
+ parent_dir_id: str,
331
+ name: str,
332
+ content: bytes | Any,
333
+ *,
334
+ check_existence: bool = True,
335
+ stream_len: int | None = None,
336
+ ) -> str:
337
+ edit_mode = False
338
+ existing_file_id: str | None = None
339
+ upload_name = name
340
+ if check_existence:
341
+ _, files = await self.list_dir(parent_dir_id, by="name")
342
+ existing_names = [item.name for item in files]
343
+ existing = _find_existing_upload_entry(files, name)
344
+ existing_file_id = existing.docid if existing is not None else None
345
+ edit_mode = existing_file_id is not None
346
+ if not edit_mode:
347
+ upload_name = _next_compact_duplicate_name(name, existing_names)
348
+ upload_info = await self._post(
349
+ "/file/osbeginupload",
350
+ {
351
+ "docid": existing_file_id if edit_mode else parent_dir_id,
352
+ "length": stream_len if stream_len is not None else len(content),
353
+ "name": None if edit_mode else upload_name,
354
+ "reqmethod": "PUT",
355
+ },
356
+ )
357
+ headers: dict[str, str] = {}
358
+ for raw_header in upload_info["authrequest"][2:]:
359
+ key, _, value = raw_header.partition(": ")
360
+ if key and value:
361
+ headers[key] = value
362
+ await network.async_put_file(
363
+ upload_info["authrequest"][1],
364
+ headers,
365
+ content,
366
+ content_length=stream_len,
367
+ client=self.client,
368
+ )
369
+ await self._post("/file/osendupload", {"docid": upload_info["docid"], "rev": upload_info["rev"]})
370
+ if edit_mode and existing is not None:
371
+ # Some AnyShare deployments keep duplicate-style names visible for a
372
+ # short while after edit-upload, so retry name normalization.
373
+ await _normalize_uploaded_name(
374
+ self,
375
+ parent_dir_id,
376
+ source_name=name,
377
+ previous_entry=existing,
378
+ )
379
+ return upload_info["docid"]
380
+
381
+ async def get_link(self, docid: str) -> LinkInfo | None:
382
+ data = await self._post("/link/getdetail", {"docid": docid})
383
+ if not data or not data.get("link"):
384
+ return None
385
+ return LinkInfo.model_validate(data)
386
+
387
+ async def create_link(
388
+ self,
389
+ docid: str,
390
+ end_time: int | None = None,
391
+ limit_times: int = -1,
392
+ *,
393
+ enable_pass: bool = False,
394
+ allow_view: bool = True,
395
+ allow_download: bool = True,
396
+ allow_upload: bool = False,
397
+ ) -> LinkInfo:
398
+ if allow_download:
399
+ allow_view = True
400
+ perm = int(allow_view) + 2 * int(allow_download) + 4 * int(allow_upload)
401
+ payload: dict[str, Any] = {
402
+ "docid": docid,
403
+ "open": enable_pass,
404
+ "limittimes": limit_times,
405
+ "perm": perm,
406
+ }
407
+ if end_time is not None:
408
+ payload["endtime"] = end_time
409
+ data = await self._post("/link/open", payload)
410
+ if data and data.get("result") == 0:
411
+ return LinkInfo.model_validate(data)
412
+ raise NeedReviewException()
413
+
414
+ async def delete_link(self, docid: str) -> None:
415
+ await self._post("/link/close", {"docid": docid})
416
+
417
+ async def search_recursive(
418
+ self,
419
+ dir_id: str,
420
+ keyword: str,
421
+ *,
422
+ max_depth: int = 3,
423
+ current_depth: int = 0,
424
+ base_path: str = "",
425
+ ) -> list[SearchResult]:
426
+ if current_depth >= max_depth:
427
+ return []
428
+ dirs, files = await self.list_dir(dir_id, by="name")
429
+ lowered = keyword.lower()
430
+ results: list[SearchResult] = []
431
+ for directory in dirs:
432
+ path = f"{base_path}/{directory.name}".strip("/")
433
+ if lowered in directory.name.lower():
434
+ results.append(
435
+ SearchResult(
436
+ path=path,
437
+ name=directory.name,
438
+ size=directory.size,
439
+ modified=directory.modified,
440
+ is_dir=True,
441
+ )
442
+ )
443
+ results.extend(
444
+ await self.search_recursive(
445
+ directory.docid,
446
+ keyword,
447
+ max_depth=max_depth,
448
+ current_depth=current_depth + 1,
449
+ base_path=path,
450
+ )
451
+ )
452
+ for file in files:
453
+ if lowered in file.name.lower():
454
+ path = f"{base_path}/{file.name}".strip("/")
455
+ results.append(
456
+ SearchResult(
457
+ path=path,
458
+ name=file.name,
459
+ size=file.size,
460
+ modified=file.modified,
461
+ is_dir=False,
462
+ )
463
+ )
464
+ return results
465
+
466
+ async def search(self, root_path: str, keyword: str, *, max_depth: int = 3) -> list[SearchResult]:
467
+ root_id = await self.get_resource_id(root_path.strip("/"))
468
+ if root_id is None:
469
+ return []
470
+ return await self.search_recursive(root_id, keyword, max_depth=max_depth, base_path=root_path.strip("/"))
471
+
472
+ async def get_revisions(self, file_id: str) -> list[RevisionInfo]:
473
+ data = await self._post("/file/revisions", {"docid": file_id})
474
+ return [RevisionInfo.model_validate(item) for item in (data or [])]
475
+
476
+ async def restore_revision(self, file_id: str, rev: str) -> dict[str, Any] | None:
477
+ return await self._post("/file/restorerevision", {"docid": file_id, "rev": rev})
pansh/auth.py ADDED
@@ -0,0 +1,152 @@
1
+ """Authentication layer — OAuth2 login flow + RSA encryption."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import re
7
+ import urllib.parse
8
+
9
+ import rsa as _rsa
10
+
11
+ from . import network
12
+
13
+ # ── RSA 加密(保留原有逻辑) ────────────────────────────────────
14
+
15
+
16
+ def rsa_encrypt(message: str, public_key: str) -> str:
17
+ """RSA + base64 加密,返回 base64 编码的密文字符串。"""
18
+ pubkey = _rsa.PublicKey.load_pkcs1_openssl_pem(public_key)
19
+ crypto = _rsa.encrypt(message.encode(), pubkey)
20
+ return base64.b64encode(crypto).decode()
21
+
22
+
23
+ # ── OAuth2 登录流程 ─────────────────────────────────────────────
24
+
25
+ _CLIENT_ID = "0f4bc444-d39a-4945-84a3-023d1f439148"
26
+ _BASIC_AUTH = "Basic MGY0YmM0NDQtZDM5YS00OTQ1LTg0YTMtMDIzZDFmNDM5MTQ4OnVOaVU0V0ZUd1FEfjE4T2JHMkU1M2dqN3ot"
27
+
28
+
29
+ def _extract_login_challenge(html: str) -> tuple[str, str]:
30
+ """从登录页面 HTML 中提取 challenge 和 csrftoken。"""
31
+ challenge = re.search(r'"challenge":"(.*?)"', html)
32
+ csrf = re.search(r'"csrftoken":"(.*?)"', html)
33
+ if not challenge or not csrf:
34
+ raise RuntimeError("无法从登录页面提取 challenge / csrftoken")
35
+ return challenge.group(1), csrf.group(1)
36
+
37
+
38
+ def _extract_code(url: str) -> str | None:
39
+ """从 anyshare:// 回调 URL 中提取 authorization code。"""
40
+ m = re.search(r"code=([^&]+)", url)
41
+ return m.group(1) if m else None
42
+
43
+
44
+ def _follow_redirects_until_anyshare(
45
+ url: str,
46
+ client: network.httpx.Client,
47
+ ) -> network.httpx.Response:
48
+ """跟踪 302 重定向直到遇到 anyshare:// scheme。"""
49
+ while True:
50
+ resp = client.get(url, follow_redirects=False)
51
+ if resp.status_code in (301, 302, 303, 307, 308):
52
+ new_url = resp.headers.get("Location", "")
53
+ if "anyshare://" in new_url:
54
+ return resp
55
+ url = new_url
56
+ else:
57
+ return resp
58
+
59
+
60
+ def get_access_token(
61
+ base_url: str,
62
+ username: str,
63
+ encrypted_password: str,
64
+ ) -> str:
65
+ """
66
+ 完整 OAuth2 登录流程,返回 access_token。
67
+
68
+ Parameters
69
+ ----------
70
+ base_url : str
71
+ e.g. ``https://bhpan.buaa.edu.cn:443/``
72
+ username : str
73
+ 学号 / 工号
74
+ encrypted_password : str
75
+ RSA + base64 加密后的密码
76
+ """
77
+ base_url = base_url.rstrip("/")
78
+ state = urllib.parse.quote(base64.b64encode(b'{"windowId":3}'))
79
+
80
+ client = network.create_client(follow_redirects=True)
81
+ try:
82
+ # Step 1: 发起 OAuth2 授权请求,获取登录页面
83
+ auth_url = (
84
+ f"{base_url}/oauth2/auth?"
85
+ f"audience=&client_id={_CLIENT_ID}"
86
+ f"&redirect_uri=anyshare%3A%2F%2Foauth2%2Flogin%2Fcallback"
87
+ f"&response_type=code&state={state}"
88
+ f"&scope=offline+openid+all&lang=zh-cn"
89
+ f"&udids=00-50-56-C0-00-01"
90
+ )
91
+ r = client.get(auth_url)
92
+ challenge, csrf_token = _extract_login_challenge(r.text)
93
+
94
+ # Step 2: 提交登录凭据
95
+ signin_body = {
96
+ "_csrf": csrf_token,
97
+ "challenge": challenge,
98
+ "account": username,
99
+ "password": encrypted_password,
100
+ "vcode": {"id": "", "content": ""},
101
+ "dualfactorauthinfo": {
102
+ "validcode": {"vcode": ""},
103
+ "OTP": {"OTP": ""},
104
+ },
105
+ "remember": False,
106
+ "device": {
107
+ "name": "RichClient",
108
+ "description": "RichClient for windows",
109
+ "client_type": "windows",
110
+ "udids": ["00-50-56-C0-00-01"],
111
+ },
112
+ }
113
+ signin_resp = network.post_json(
114
+ f"{base_url}/oauth2/signin",
115
+ signin_body,
116
+ client=client,
117
+ )
118
+
119
+ # Step 3: 跟踪重定向获取 authorization code
120
+ redirect_url = signin_resp["redirect"] # type: ignore[index]
121
+ redir_resp = _follow_redirects_until_anyshare(redirect_url, client)
122
+
123
+ location = redir_resp.headers.get("Location", "")
124
+ code = _extract_code(location)
125
+ if code is None:
126
+ raise RuntimeError(f"无法从回调 URL 中提取 code: {location}")
127
+
128
+ # Step 4: 用 code 换取 access_token
129
+ boundary = "----WebKitFormBoundarywPAfbB36kbRTzgzy"
130
+ token_body = (
131
+ f"------WebKitFormBoundarywPAfbB36kbRTzgzy\r\n"
132
+ f'Content-Disposition: form-data; name="grant_type"\r\n\r\n'
133
+ f"authorization_code\r\n"
134
+ f"------WebKitFormBoundarywPAfbB36kbRTzgzy\r\n"
135
+ f'Content-Disposition: form-data; name="code"\r\n\r\n'
136
+ f"{code}\r\n"
137
+ f"------WebKitFormBoundarywPAfbB36kbRTzgzy\r\n"
138
+ f'Content-Disposition: form-data; name="redirect_uri"\r\n\r\n'
139
+ f"anyshare://oauth2/login/callback\r\n"
140
+ f"------WebKitFormBoundarywPAfbB36kbRTzgzy--"
141
+ )
142
+ token_resp = client.post(
143
+ f"{base_url}/oauth2/token",
144
+ headers={
145
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
146
+ "Authorization": _BASIC_AUTH,
147
+ },
148
+ content=token_body.encode(),
149
+ )
150
+ return token_resp.json()["access_token"]
151
+ finally:
152
+ client.close()