parsehub 2.0.19__tar.gz → 2.0.20__tar.gz

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.
Files changed (70) hide show
  1. {parsehub-2.0.19/src/parsehub.egg-info → parsehub-2.0.20}/PKG-INFO +1 -2
  2. {parsehub-2.0.19 → parsehub-2.0.20}/pyproject.toml +1 -2
  3. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/cli.py +71 -29
  4. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/cli_config.py +6 -63
  5. {parsehub-2.0.19 → parsehub-2.0.20/src/parsehub.egg-info}/PKG-INFO +1 -2
  6. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub.egg-info/requires.txt +0 -1
  7. {parsehub-2.0.19 → parsehub-2.0.20}/test/test_cli.py +59 -27
  8. parsehub-2.0.20/test/test_cli_config.py +49 -0
  9. parsehub-2.0.19/test/test_cli_config.py +0 -79
  10. {parsehub-2.0.19 → parsehub-2.0.20}/LICENSE +0 -0
  11. {parsehub-2.0.19 → parsehub-2.0.20}/README.md +0 -0
  12. {parsehub-2.0.19 → parsehub-2.0.20}/setup.cfg +0 -0
  13. {parsehub-2.0.19 → parsehub-2.0.20}/src/__init__.py +0 -0
  14. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/__init__.py +0 -0
  15. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/config/__init__.py +0 -0
  16. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/config/config.py +0 -0
  17. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/errors.py +0 -0
  18. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/__init__.py +0 -0
  19. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/base/__init__.py +0 -0
  20. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/base/base.py +0 -0
  21. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/base/ytdlp.py +0 -0
  22. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/__init__.py +0 -0
  23. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/bilibili.py +0 -0
  24. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/coolapk.py +0 -0
  25. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/douyin.py +0 -0
  26. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/facebook.py +0 -0
  27. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/instagram.py +0 -0
  28. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/kuaishou.py +0 -0
  29. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/pipix.py +0 -0
  30. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/threads.py +0 -0
  31. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/tieba.py +0 -0
  32. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/tiktok.py +0 -0
  33. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/twitter.py +0 -0
  34. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/weibo.py +0 -0
  35. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/weixin.py +0 -0
  36. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/xhs.py +0 -0
  37. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
  38. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/youtube.py +0 -0
  39. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/parsers/parser/zuiyou.py +0 -0
  40. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/provider_api/__init__.py +0 -0
  41. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/provider_api/bilibili.py +0 -0
  42. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/provider_api/coolapk.py +0 -0
  43. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/provider_api/douyin.py +0 -0
  44. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/provider_api/instagram.py +0 -0
  45. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/provider_api/kuaishou.py +0 -0
  46. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/provider_api/pipix.py +0 -0
  47. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/provider_api/threads.py +0 -0
  48. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/provider_api/tieba.py +0 -0
  49. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/provider_api/tiktok.py +0 -0
  50. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/provider_api/twitter.py +0 -0
  51. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/provider_api/weibo.py +0 -0
  52. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/provider_api/weixin.py +0 -0
  53. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/provider_api/xhs.py +0 -0
  54. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/provider_api/xiaoheihe.py +0 -0
  55. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/provider_api/zuiyou.py +0 -0
  56. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/types/__init__.py +0 -0
  57. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/types/callback.py +0 -0
  58. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/types/media_file.py +0 -0
  59. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/types/media_ref.py +0 -0
  60. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/types/platform.py +0 -0
  61. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/types/post.py +0 -0
  62. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/types/result.py +0 -0
  63. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/utils/downloader.py +0 -0
  64. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/utils/media_info.py +0 -0
  65. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub/utils/utils.py +0 -0
  66. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub.egg-info/SOURCES.txt +0 -0
  67. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub.egg-info/dependency_links.txt +0 -0
  68. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub.egg-info/entry_points.txt +0 -0
  69. {parsehub-2.0.19 → parsehub-2.0.20}/src/parsehub.egg-info/top_level.txt +0 -0
  70. {parsehub-2.0.19 → parsehub-2.0.20}/test/test_core_offline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.0.19
3
+ Version: 2.0.20
4
4
  Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
5
5
  Author-email: 梓澪 <zilingmio@gmail.com>
6
6
  License: MIT
@@ -37,7 +37,6 @@ Requires-Dist: cryptography>=46.0.6
37
37
  Requires-Dist: gmssl>=3.2.2
38
38
  Provides-Extra: cli
39
39
  Requires-Dist: argcomplete>=3.6.3; extra == "cli"
40
- Requires-Dist: keyring>=25.6.0; extra == "cli"
41
40
  Requires-Dist: platformdirs>=4.5.1; extra == "cli"
42
41
  Dynamic: license-file
43
42
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "parsehub"
3
- version = "2.0.19"
3
+ version = "2.0.20"
4
4
  description = "轻量、异步、开箱即用的社交媒体聚合解析库"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12.0"
@@ -47,7 +47,6 @@ ph = "parsehub.cli:main"
47
47
  [project.optional-dependencies]
48
48
  cli = [
49
49
  "argcomplete>=3.6.3",
50
- "keyring>=25.6.0",
51
50
  "platformdirs>=4.5.1",
52
51
  ]
53
52
 
@@ -1,18 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import importlib.util
4
5
  import json
5
6
  import sys
6
7
  import unicodedata
7
8
  from dataclasses import asdict, is_dataclass
8
9
  from pathlib import Path
9
- from typing import Any
10
+ from typing import TYPE_CHECKING, Any
10
11
 
11
- from . import ParseHub
12
- from .cli_config import AutoCookieStore, ConfigStore, CookiePrompt, PlatformConfig
13
- from .errors import ParseHubError
12
+ if TYPE_CHECKING:
13
+ from .cli_config import AutoCookieStore, PlatformConfig
14
14
 
15
15
  _COMMANDS = {"parse", "p", "download", "d", "dl", "platforms", "ls", "set"}
16
+ _CLI_EXTRA_MODULES = ("argcomplete", "platformdirs")
16
17
 
17
18
 
18
19
  class _ChineseHelpFormatter(argparse.RawDescriptionHelpFormatter):
@@ -48,15 +49,21 @@ class _ChineseArgumentParser(argparse.ArgumentParser):
48
49
 
49
50
  def main(argv: list[str] | None = None) -> int:
50
51
  raw_argv = list(sys.argv[1:] if argv is None else argv)
52
+ if not _has_cli_extra_dependencies():
53
+ _print_cli_extra_hint()
54
+ return 1
51
55
  parser = _build_parser(Path(sys.argv[0]).name if argv is None else "parsehub")
52
56
  _enable_completion(parser)
57
+ if not raw_argv:
58
+ parser.print_help()
59
+ return 0
53
60
  try:
54
61
  args = parser.parse_args(_normalize_argv(raw_argv))
55
62
  _finalize_output_args(args)
56
63
  return args.func(args)
57
64
  except SystemExit as e:
58
65
  return _normalize_exit_code(e.code)
59
- except (ParseHubError, ValueError) as e:
66
+ except ValueError as e:
60
67
  _print_error(e)
61
68
  return 1
62
69
  except KeyboardInterrupt:
@@ -221,7 +228,7 @@ def _add_json_options(parser: argparse.ArgumentParser) -> None:
221
228
 
222
229
 
223
230
  def _cmd_parse(args: argparse.Namespace) -> int:
224
- hub = ParseHub()
231
+ hub = _new_parsehub()
225
232
  platform_id = _detect_platform_id(hub, args.url_or_text)
226
233
  config = _load_platform_config(platform_id)
227
234
  proxy = args.proxy if args.proxy is not None else config.parse_proxy
@@ -235,7 +242,7 @@ def _cmd_parse(args: argparse.Namespace) -> int:
235
242
 
236
243
 
237
244
  def _cmd_download(args: argparse.Namespace) -> int:
238
- hub = ParseHub()
245
+ hub = _new_parsehub()
239
246
  platform_id = _detect_platform_id(hub, args.url_or_text)
240
247
  config = _load_platform_config(platform_id)
241
248
  proxy = args.proxy if args.proxy is not None else config.download_proxy
@@ -265,7 +272,7 @@ def _cmd_download(args: argparse.Namespace) -> int:
265
272
 
266
273
 
267
274
  def _cmd_platforms(args: argparse.Namespace) -> int:
268
- platforms = ParseHub().get_platforms()
275
+ platforms = _new_parsehub().get_platforms()
269
276
  if args.json:
270
277
  _print_json(platforms, pretty=args.pretty)
271
278
  else:
@@ -284,7 +291,7 @@ def _cmd_platform_list(args: argparse.Namespace) -> int:
284
291
 
285
292
  def _cmd_platform_show(args: argparse.Namespace) -> int:
286
293
  platform = _validate_platform(args.platform)
287
- config = ConfigStore().get_platform(platform)
294
+ config = _config_store().get_platform(platform)
288
295
  data = _platform_config_row(_platform_info_map().get(platform, {"id": platform, "name": platform}), config)
289
296
  if args.json:
290
297
  _print_json(data, pretty=args.pretty)
@@ -298,7 +305,7 @@ def _cmd_platform_proxy(args: argparse.Namespace) -> int:
298
305
  if args.clear:
299
306
  if args.proxy:
300
307
  raise ValueError("清除代理时不需要填写代理地址。\n示例: parsehub set proxy xhs --clear")
301
- changed = ConfigStore().clear_proxy(platform, args.proxy_target)
308
+ changed = _config_store().clear_proxy(platform, args.proxy_target)
302
309
  if changed:
303
310
  print(f"已清除 {platform} 的{_proxy_target_label(args.proxy_target)}。")
304
311
  else:
@@ -306,7 +313,7 @@ def _cmd_platform_proxy(args: argparse.Namespace) -> int:
306
313
  return 0
307
314
  if not args.proxy:
308
315
  raise ValueError("缺少代理地址。\n示例: parsehub set proxy xhs http://127.0.0.1:7890")
309
- ConfigStore().set_proxy(platform, args.proxy, args.proxy_target)
316
+ _config_store().set_proxy(platform, args.proxy, args.proxy_target)
310
317
  print(f"已设置 {platform} 的{_proxy_target_label(args.proxy_target)}。")
311
318
  print(f"代理地址: {args.proxy}")
312
319
  return 0
@@ -314,14 +321,11 @@ def _cmd_platform_proxy(args: argparse.Namespace) -> int:
314
321
 
315
322
  def _cmd_platform_cookie(args: argparse.Namespace) -> int:
316
323
  platform = _validate_platform(args.platform)
317
- store = AutoCookieStore()
324
+ store = _cookie_store()
318
325
  if args.clear:
319
326
  print(f"已清除 {platform} Cookie。" if store.delete(platform) else f"{platform} 还没有保存 Cookie,无需清除。")
320
327
  return 0
321
- storage = store.set(platform, CookiePrompt().read(platform))
322
- if storage == "file":
323
- print("提示: 系统密钥库不可用,Cookie 已保存到本地 cookies.toml。", file=sys.stderr)
324
- print(" 请不要把该文件提交到 git。", file=sys.stderr)
328
+ store.set(platform, _cookie_prompt().read(platform))
325
329
  print(f"已保存 {platform} Cookie。之后解析或下载该平台内容时会自动使用。")
326
330
  return 0
327
331
 
@@ -333,16 +337,46 @@ def _print_error(error: Exception) -> None:
333
337
  print(f" {line}", file=sys.stderr)
334
338
 
335
339
 
340
+ def _new_parsehub() -> Any:
341
+ from . import ParseHub
342
+
343
+ return ParseHub()
344
+
345
+
346
+ def _platform_config_type() -> type:
347
+ from .cli_config import PlatformConfig
348
+
349
+ return PlatformConfig
350
+
351
+
352
+ def _config_store() -> Any:
353
+ from .cli_config import ConfigStore
354
+
355
+ return ConfigStore()
356
+
357
+
358
+ def _cookie_store() -> Any:
359
+ from .cli_config import AutoCookieStore
360
+
361
+ return AutoCookieStore()
362
+
363
+
364
+ def _cookie_prompt() -> Any:
365
+ from .cli_config import CookiePrompt
366
+
367
+ return CookiePrompt()
368
+
369
+
336
370
  def _load_platform_config(platform_id: str | None) -> PlatformConfig:
337
371
  if not platform_id:
338
- return PlatformConfig()
339
- return ConfigStore().get_platform(platform_id)
372
+ return _platform_config_type()()
373
+ return _config_store().get_platform(platform_id)
340
374
 
341
375
 
342
376
  def _load_cookie(platform_id: str | None) -> str | None:
343
377
  if not platform_id:
344
378
  return None
345
- return AutoCookieStore().get(platform_id)
379
+ return _cookie_store().get(platform_id)
346
380
 
347
381
 
348
382
  def _detect_platform_id(hub: Any, url_or_text: str) -> str | None:
@@ -372,19 +406,19 @@ def _validate_platform(platform: str) -> str:
372
406
 
373
407
 
374
408
  def _supported_platform_ids() -> list[str]:
375
- return [str(platform.get("id")) for platform in ParseHub().get_platforms() if platform.get("id")]
409
+ return [str(platform.get("id")) for platform in _new_parsehub().get_platforms() if platform.get("id")]
376
410
 
377
411
 
378
412
  def _platform_info_map() -> dict[str, dict[str, Any]]:
379
- return {str(platform.get("id")): platform for platform in ParseHub().get_platforms() if platform.get("id")}
413
+ return {str(platform.get("id")): platform for platform in _new_parsehub().get_platforms() if platform.get("id")}
380
414
 
381
415
 
382
416
  def _platform_config_rows() -> list[dict[str, Any]]:
383
- config_store = ConfigStore()
384
- cookie_store = AutoCookieStore()
417
+ config_store = _config_store()
418
+ cookie_store = _cookie_store()
385
419
  return [
386
420
  _platform_config_row(platform, config_store.get_platform(str(platform["id"])), cookie_store=cookie_store)
387
- for platform in ParseHub().get_platforms()
421
+ for platform in _new_parsehub().get_platforms()
388
422
  ]
389
423
 
390
424
 
@@ -396,7 +430,7 @@ def _platform_config_row(
396
430
  ) -> dict[str, Any]:
397
431
  platform_id = str(platform.get("id") or "")
398
432
  if cookie_store is None:
399
- cookie_store = AutoCookieStore()
433
+ cookie_store = _cookie_store()
400
434
  return {
401
435
  "id": platform_id,
402
436
  "name": str(platform.get("name") or ""),
@@ -595,11 +629,19 @@ def _complete_platforms(prefix: str, **_: Any) -> list[str]:
595
629
  return [platform for platform in _supported_platform_ids() if platform.startswith(prefix)]
596
630
 
597
631
 
632
+ def _has_cli_extra_dependencies() -> bool:
633
+ return all(importlib.util.find_spec(module) is not None for module in _CLI_EXTRA_MODULES)
634
+
635
+
636
+ def _print_cli_extra_hint() -> None:
637
+ print("错误: 未安装 ParseHub CLI 扩展依赖。", file=sys.stderr)
638
+ print('请运行: pip install "parsehub[cli]"', file=sys.stderr)
639
+ print('如果使用 uv: uv add "parsehub[cli]"', file=sys.stderr)
640
+
641
+
598
642
  def _enable_completion(parser: argparse.ArgumentParser) -> None:
599
- try:
600
- import argcomplete
601
- except Exception:
602
- return
643
+ import argcomplete
644
+
603
645
  argcomplete.autocomplete(parser)
604
646
 
605
647
 
@@ -79,47 +79,6 @@ class ConfigStore:
79
79
  return changed
80
80
 
81
81
 
82
- class KeyringUnavailable(RuntimeError):
83
- pass
84
-
85
-
86
- class KeyringCookieStore:
87
- def __init__(self, service: str = "parsehub"):
88
- self.service = service
89
-
90
- def set(self, platform: str, cookie: str) -> None:
91
- keyring = self._keyring()
92
- try:
93
- keyring.set_password(self.service, self._username(platform), cookie)
94
- except Exception as e:
95
- raise KeyringUnavailable(str(e)) from e
96
-
97
- def get(self, platform: str) -> str | None:
98
- keyring = self._keyring()
99
- try:
100
- return keyring.get_password(self.service, self._username(platform))
101
- except Exception as e:
102
- raise KeyringUnavailable(str(e)) from e
103
-
104
- def delete(self, platform: str) -> bool:
105
- try:
106
- keyring = self._keyring()
107
- keyring.delete_password(self.service, self._username(platform))
108
- return True
109
- except Exception:
110
- return False
111
-
112
- def _keyring(self) -> Any:
113
- try:
114
- import keyring
115
- except Exception as e:
116
- raise KeyringUnavailable("keyring 未安装") from e
117
- return keyring
118
-
119
- def _username(self, platform: str) -> str:
120
- return f"cookie:{platform}"
121
-
122
-
123
82
  class FileCookieStore:
124
83
  def __init__(self, path: Path | None = None):
125
84
  self.path = default_cookie_path() if path is None else Path(path)
@@ -163,36 +122,20 @@ class FileCookieStore:
163
122
 
164
123
 
165
124
  class AutoCookieStore:
166
- def __init__(
167
- self,
168
- keyring_store: KeyringCookieStore | None = None,
169
- file_store: FileCookieStore | None = None,
170
- ):
171
- self.keyring_store = KeyringCookieStore() if keyring_store is None else keyring_store
125
+ def __init__(self, file_store: FileCookieStore | None = None):
172
126
  self.file_store = FileCookieStore() if file_store is None else file_store
173
127
 
174
- def set(self, platform: str, cookie: str) -> Literal["keyring", "file"]:
175
- try:
176
- self.keyring_store.set(platform, cookie)
177
- self.file_store.delete(platform)
178
- return "keyring"
179
- except KeyringUnavailable:
180
- self.file_store.set(platform, cookie)
181
- return "file"
128
+ def set(self, platform: str, cookie: str) -> None:
129
+ self.file_store.set(platform, cookie)
182
130
 
183
131
  def get(self, platform: str) -> str | None:
184
- try:
185
- value = self.keyring_store.get(platform)
186
- except KeyringUnavailable:
187
- value = None
188
- return value or self.file_store.get(platform)
132
+ return self.file_store.get(platform)
189
133
 
190
134
  def delete(self, platform: str) -> bool:
191
- deleted = self.keyring_store.delete(platform)
192
- return self.file_store.delete(platform) or deleted
135
+ return self.file_store.delete(platform)
193
136
 
194
137
  def exists(self, platform: str) -> bool:
195
- return self.get(platform) is not None
138
+ return self.file_store.exists(platform)
196
139
 
197
140
 
198
141
  class CookiePrompt:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.0.19
3
+ Version: 2.0.20
4
4
  Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
5
5
  Author-email: 梓澪 <zilingmio@gmail.com>
6
6
  License: MIT
@@ -37,7 +37,6 @@ Requires-Dist: cryptography>=46.0.6
37
37
  Requires-Dist: gmssl>=3.2.2
38
38
  Provides-Extra: cli
39
39
  Requires-Dist: argcomplete>=3.6.3; extra == "cli"
40
- Requires-Dist: keyring>=25.6.0; extra == "cli"
41
40
  Requires-Dist: platformdirs>=4.5.1; extra == "cli"
42
41
  Dynamic: license-file
43
42
 
@@ -21,5 +21,4 @@ gmssl>=3.2.2
21
21
 
22
22
  [cli]
23
23
  argcomplete>=3.6.3
24
- keyring>=25.6.0
25
24
  platformdirs>=4.5.1
@@ -5,7 +5,7 @@ import tempfile
5
5
  import unittest
6
6
  from dataclasses import dataclass
7
7
  from pathlib import Path
8
- from unittest.mock import patch
8
+ from unittest.mock import Mock, patch
9
9
 
10
10
  from src.parsehub import cli
11
11
  from src.parsehub.cli_config import ConfigStore, FileCookieStore
@@ -118,8 +118,8 @@ class TestCli(unittest.TestCase):
118
118
  self.config_path = self.config_dir / "config.toml"
119
119
  self.cookie_path = self.config_dir / "cookies.toml"
120
120
  self.patches = [
121
- patch.object(cli, "ConfigStore", lambda: ConfigStore(self.config_path)),
122
- patch.object(cli, "AutoCookieStore", lambda: FileCookieStore(self.cookie_path)),
121
+ patch.object(cli, "_config_store", lambda: ConfigStore(self.config_path)),
122
+ patch.object(cli, "_cookie_store", lambda: FileCookieStore(self.cookie_path)),
123
123
  ]
124
124
  for item in self.patches:
125
125
  item.start()
@@ -128,12 +128,42 @@ class TestCli(unittest.TestCase):
128
128
  def run_cli(self, argv):
129
129
  stdout = io.StringIO()
130
130
  stderr = io.StringIO()
131
- with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
131
+ with (
132
+ patch.object(cli, "_has_cli_extra_dependencies", return_value=True),
133
+ patch.object(cli, "_enable_completion", return_value=None),
134
+ contextlib.redirect_stdout(stdout),
135
+ contextlib.redirect_stderr(stderr),
136
+ ):
132
137
  code = cli.main(argv)
133
138
  return code, stdout.getvalue(), stderr.getvalue()
134
139
 
140
+ def test_missing_cli_extra_prints_install_hint(self):
141
+ stdout = io.StringIO()
142
+ stderr = io.StringIO()
143
+ with (
144
+ patch.object(cli, "_has_cli_extra_dependencies", return_value=False),
145
+ patch.object(cli, "_build_parser") as build_parser,
146
+ contextlib.redirect_stdout(stdout),
147
+ contextlib.redirect_stderr(stderr),
148
+ ):
149
+ code = cli.main(["platforms"])
150
+
151
+ self.assertEqual(code, 1)
152
+ self.assertEqual(stdout.getvalue(), "")
153
+ self.assertIn("未安装 ParseHub CLI 扩展依赖", stderr.getvalue())
154
+ self.assertIn('pip install "parsehub[cli]"', stderr.getvalue())
155
+ build_parser.assert_not_called()
156
+
157
+ def test_empty_args_print_help(self):
158
+ code, stdout, stderr = self.run_cli([])
159
+
160
+ self.assertEqual(code, 0)
161
+ self.assertEqual(stderr, "")
162
+ self.assertIn("ParseHub 命令行工具", stdout)
163
+ self.assertIn("用法:", stdout)
164
+
135
165
  def test_parse_defaults_to_human_readable_chinese_summary(self):
136
- with patch.object(cli, "ParseHub", FakeParseHub):
166
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
137
167
  code, stdout, stderr = self.run_cli(
138
168
  ["parse", "分享 https://example.com/post/1", "--proxy", "http://proxy", "--cookie", "a=b"]
139
169
  )
@@ -148,7 +178,7 @@ class TestCli(unittest.TestCase):
148
178
  )
149
179
 
150
180
  def test_parse_json_outputs_json(self):
151
- with patch.object(cli, "ParseHub", FakeParseHub):
181
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
152
182
  code, stdout, stderr = self.run_cli(["parse", "https://example.com/post/1", "--json"])
153
183
 
154
184
  self.assertEqual(code, 0)
@@ -156,7 +186,7 @@ class TestCli(unittest.TestCase):
156
186
  self.assertEqual(json.loads(stdout)["title"], "标题")
157
187
 
158
188
  def test_parse_compact_outputs_single_line_json(self):
159
- with patch.object(cli, "ParseHub", FakeParseHub):
189
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
160
190
  code, stdout, stderr = self.run_cli(["parse", "https://example.com/post/1", "--compact"])
161
191
 
162
192
  self.assertEqual(code, 0)
@@ -165,7 +195,7 @@ class TestCli(unittest.TestCase):
165
195
  self.assertEqual(json.loads(stdout)["platform"], "xhs")
166
196
 
167
197
  def test_short_parse_alias_and_default_parse(self):
168
- with patch.object(cli, "ParseHub", FakeParseHub):
198
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
169
199
  alias_code, alias_stdout, alias_stderr = self.run_cli(["p", "https://example.com/post/1"])
170
200
  default_code, default_stdout, default_stderr = self.run_cli(["https://example.com/post/2"])
171
201
 
@@ -179,7 +209,7 @@ class TestCli(unittest.TestCase):
179
209
  self.assertEqual(FakeParseHub.instances[1].parse_calls[0]["url"], "https://example.com/post/2")
180
210
 
181
211
  def test_download_outputs_summary_progress_and_forwards_options(self):
182
- with patch.object(cli, "ParseHub", FakeParseHub):
212
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
183
213
  code, stdout, stderr = self.run_cli(
184
214
  [
185
215
  "download",
@@ -211,7 +241,7 @@ class TestCli(unittest.TestCase):
211
241
  self.assertTrue(call["save_metadata"])
212
242
 
213
243
  def test_short_download_alias_outputs_json_and_forwards_output_dir(self):
214
- with patch.object(cli, "ParseHub", FakeParseHub):
244
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
215
245
  code, stdout, stderr = self.run_cli(["d", "https://example.com/post/1", "--output-dir", "./out", "--json"])
216
246
 
217
247
  self.assertEqual(code, 0)
@@ -222,7 +252,7 @@ class TestCli(unittest.TestCase):
222
252
  self.assertEqual(FakeParseHub.instances[0].download_calls[0]["path"], "./out")
223
253
 
224
254
  def test_download_quiet_suppresses_feedback_and_progress_callback(self):
225
- with patch.object(cli, "ParseHub", FakeParseHub):
255
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
226
256
  code, stdout, stderr = self.run_cli(["dl", "https://example.com/post/1", "--quiet"])
227
257
 
228
258
  self.assertEqual(code, 0)
@@ -231,7 +261,7 @@ class TestCli(unittest.TestCase):
231
261
  self.assertIsNone(FakeParseHub.instances[0].download_calls[0]["callback"])
232
262
 
233
263
  def test_download_no_progress_keeps_status_but_disables_callback(self):
234
- with patch.object(cli, "ParseHub", FakeParseHub):
264
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
235
265
  code, stdout, stderr = self.run_cli(["download", "https://example.com/post/1", "--no-progress"])
236
266
 
237
267
  self.assertEqual(code, 0)
@@ -241,7 +271,7 @@ class TestCli(unittest.TestCase):
241
271
  self.assertIsNone(FakeParseHub.instances[0].download_calls[0]["callback"])
242
272
 
243
273
  def test_download_defaults_path_to_cwd_downloads(self):
244
- with patch.object(cli, "ParseHub", FakeParseHub):
274
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
245
275
  code, stdout, stderr = self.run_cli(["download", "https://example.com/post/1", "--quiet"])
246
276
 
247
277
  self.assertEqual(code, 0)
@@ -249,7 +279,7 @@ class TestCli(unittest.TestCase):
249
279
  self.assertEqual(FakeParseHub.instances[0].download_calls[0]["path"], Path.cwd() / "downloads")
250
280
 
251
281
  def test_platforms_outputs_aligned_human_readable_table(self):
252
- with patch.object(cli, "ParseHub", FakeParseHub):
282
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
253
283
  code, stdout, stderr = self.run_cli(["platforms"])
254
284
 
255
285
  self.assertEqual(code, 0)
@@ -261,7 +291,7 @@ class TestCli(unittest.TestCase):
261
291
  self.assertEqual(lines[3], "weibo 微博 视频")
262
292
 
263
293
  def test_short_platforms_alias_outputs_json(self):
264
- with patch.object(cli, "ParseHub", FakeParseHub):
294
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
265
295
  code, stdout, stderr = self.run_cli(["ls", "--json"])
266
296
 
267
297
  self.assertEqual(code, 0)
@@ -275,7 +305,7 @@ class TestCli(unittest.TestCase):
275
305
  )
276
306
 
277
307
  def test_set_proxy_sets_and_shows_parse_and_download_proxy(self):
278
- with patch.object(cli, "ParseHub", FakeParseHub):
308
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
279
309
  set_code, set_stdout, set_stderr = self.run_cli(["set", "proxy", "xhs", "http://proxy"])
280
310
  show_code, show_stdout, show_stderr = self.run_cli(["set", "show", "xhs"])
281
311
 
@@ -291,7 +321,7 @@ class TestCli(unittest.TestCase):
291
321
  self.assertIn('download_proxy = "http://proxy"', self.config_path.read_text())
292
322
 
293
323
  def test_set_proxy_supports_targeted_clear(self):
294
- with patch.object(cli, "ParseHub", FakeParseHub):
324
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
295
325
  self.run_cli(["set", "proxy", "xhs", "http://parse", "--for", "parse"])
296
326
  self.run_cli(["set", "proxy", "xhs", "http://download", "--for", "download"])
297
327
  code, stdout, stderr = self.run_cli(["set", "proxy", "xhs", "--clear", "--for", "parse"])
@@ -306,9 +336,11 @@ class TestCli(unittest.TestCase):
306
336
  self.assertIn("下载代理: http://download", show_stdout)
307
337
 
308
338
  def test_set_cookie_sets_lists_and_clears_cookie_without_printing_value(self):
339
+ prompt = Mock()
340
+ prompt.read.return_value = "a=b; token=secret"
309
341
  with (
310
- patch.object(cli, "ParseHub", FakeParseHub),
311
- patch.object(cli.CookiePrompt, "read", return_value="a=b; token=secret"),
342
+ patch.object(cli, "_new_parsehub", FakeParseHub),
343
+ patch.object(cli, "_cookie_prompt", return_value=prompt),
312
344
  ):
313
345
  set_code, set_stdout, set_stderr = self.run_cli(["set", "cookie", "xhs"])
314
346
  list_code, list_stdout, list_stderr = self.run_cli(["set", "list"])
@@ -330,7 +362,7 @@ class TestCli(unittest.TestCase):
330
362
  def test_parse_uses_saved_platform_proxy_and_cookie(self):
331
363
  ConfigStore(self.config_path).set_proxy("xhs", "http://parse-proxy", "parse")
332
364
  FileCookieStore(self.cookie_path).set("xhs", "saved=cookie")
333
- with patch.object(cli, "ParseHub", FakeParseHub):
365
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
334
366
  code, stdout, stderr = self.run_cli(["parse", "https://example.com/post/1"])
335
367
 
336
368
  self.assertEqual(code, 0)
@@ -344,7 +376,7 @@ class TestCli(unittest.TestCase):
344
376
  store.set_proxy("xhs", "http://parse-proxy", "parse")
345
377
  store.set_proxy("xhs", "http://download-proxy", "download")
346
378
  FileCookieStore(self.cookie_path).set("xhs", "saved=cookie")
347
- with patch.object(cli, "ParseHub", FakeParseHub):
379
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
348
380
  code, stdout, stderr = self.run_cli(["download", "https://example.com/post/1", "--quiet"])
349
381
 
350
382
  self.assertEqual(code, 0)
@@ -358,7 +390,7 @@ class TestCli(unittest.TestCase):
358
390
  def test_cli_options_override_saved_platform_config(self):
359
391
  ConfigStore(self.config_path).set_proxy("xhs", "http://saved-proxy", "all")
360
392
  FileCookieStore(self.cookie_path).set("xhs", "saved=cookie")
361
- with patch.object(cli, "ParseHub", FakeParseHub):
393
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
362
394
  code, stdout, stderr = self.run_cli(
363
395
  [
364
396
  "download",
@@ -382,7 +414,7 @@ class TestCli(unittest.TestCase):
382
414
  self.assertEqual(call["parse_cookie"], "cli=cookie")
383
415
 
384
416
  def test_parsehub_error_returns_one(self):
385
- with patch.object(cli, "ParseHub", ErrorParseHub):
417
+ with patch.object(cli, "_new_parsehub", ErrorParseHub):
386
418
  code, stdout, stderr = self.run_cli(["parse", "https://example.com/post/1"])
387
419
 
388
420
  self.assertEqual(code, 1)
@@ -391,7 +423,7 @@ class TestCli(unittest.TestCase):
391
423
  self.assertIn("错误", stderr)
392
424
 
393
425
  def test_value_error_returns_one(self):
394
- with patch.object(cli, "ParseHub", ValueErrorParseHub):
426
+ with patch.object(cli, "_new_parsehub", ValueErrorParseHub):
395
427
  code, stdout, stderr = self.run_cli(["parse", "https://example.com/post/1"])
396
428
 
397
429
  self.assertEqual(code, 1)
@@ -400,7 +432,7 @@ class TestCli(unittest.TestCase):
400
432
  self.assertIn("错误", stderr)
401
433
 
402
434
  def test_keyboard_interrupt_returns_130(self):
403
- with patch.object(cli, "ParseHub", KeyboardInterruptParseHub):
435
+ with patch.object(cli, "_new_parsehub", KeyboardInterruptParseHub):
404
436
  code, stdout, stderr = self.run_cli(["parse", "https://example.com/post/1"])
405
437
 
406
438
  self.assertEqual(code, 130)
@@ -426,7 +458,7 @@ class TestCli(unittest.TestCase):
426
458
  self.assertIn("parsehub set proxy xhs", stdout)
427
459
 
428
460
  def test_set_proxy_missing_value_shows_actionable_example(self):
429
- with patch.object(cli, "ParseHub", FakeParseHub):
461
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
430
462
  code, stdout, stderr = self.run_cli(["set", "proxy", "xhs"])
431
463
 
432
464
  self.assertEqual(code, 1)
@@ -436,7 +468,7 @@ class TestCli(unittest.TestCase):
436
468
  self.assertIn("\n 示例:", stderr)
437
469
 
438
470
  def test_unknown_platform_error_lists_next_step(self):
439
- with patch.object(cli, "ParseHub", FakeParseHub):
471
+ with patch.object(cli, "_new_parsehub", FakeParseHub):
440
472
  code, stdout, stderr = self.run_cli(["set", "show", "unknown"])
441
473
 
442
474
  self.assertEqual(code, 1)
@@ -0,0 +1,49 @@
1
+ import tempfile
2
+ import unittest
3
+ from pathlib import Path
4
+
5
+ from src.parsehub.cli_config import AutoCookieStore, ConfigStore, FileCookieStore
6
+
7
+
8
+ class TestCliConfig(unittest.TestCase):
9
+ def setUp(self):
10
+ self.tmp = tempfile.TemporaryDirectory()
11
+ self.addCleanup(self.tmp.cleanup)
12
+ self.base = Path(self.tmp.name)
13
+
14
+ def test_config_store_sets_and_clears_targeted_proxy(self):
15
+ store = ConfigStore(self.base / "config.toml")
16
+
17
+ store.set_proxy("xhs", "http://parse", "parse")
18
+ store.set_proxy("xhs", "http://download", "download")
19
+
20
+ config = store.get_platform("xhs")
21
+ self.assertEqual(config.parse_proxy, "http://parse")
22
+ self.assertEqual(config.download_proxy, "http://download")
23
+
24
+ self.assertTrue(store.clear_proxy("xhs", "parse"))
25
+ config = store.get_platform("xhs")
26
+ self.assertIsNone(config.parse_proxy)
27
+ self.assertEqual(config.download_proxy, "http://download")
28
+
29
+ def test_auto_cookie_store_uses_private_file(self):
30
+ path = self.base / "cookies.toml"
31
+ store = AutoCookieStore(file_store=FileCookieStore(path))
32
+
33
+ store.set("xhs", "a=b")
34
+
35
+ self.assertEqual(store.get("xhs"), "a=b")
36
+ self.assertEqual(path.stat().st_mode & 0o777, 0o600)
37
+
38
+ def test_auto_cookie_store_deletes_private_file_cookie(self):
39
+ path = self.base / "cookies.toml"
40
+ store = AutoCookieStore(file_store=FileCookieStore(path))
41
+ store.set("xhs", "a=b")
42
+
43
+ self.assertTrue(store.delete("xhs"))
44
+
45
+ self.assertFalse(store.exists("xhs"))
46
+
47
+
48
+ if __name__ == "__main__":
49
+ unittest.main()
@@ -1,79 +0,0 @@
1
- import tempfile
2
- import unittest
3
- from pathlib import Path
4
-
5
- from src.parsehub.cli_config import AutoCookieStore, ConfigStore, FileCookieStore, KeyringUnavailable
6
-
7
-
8
- class UnavailableKeyringStore:
9
- def set(self, platform, cookie):
10
- raise KeyringUnavailable("unavailable")
11
-
12
- def get(self, platform):
13
- raise KeyringUnavailable("unavailable")
14
-
15
- def delete(self, platform):
16
- return False
17
-
18
-
19
- class MemoryKeyringStore:
20
- def __init__(self):
21
- self.values = {}
22
-
23
- def set(self, platform, cookie):
24
- self.values[platform] = cookie
25
-
26
- def get(self, platform):
27
- return self.values.get(platform)
28
-
29
- def delete(self, platform):
30
- return self.values.pop(platform, None) is not None
31
-
32
-
33
- class TestCliConfig(unittest.TestCase):
34
- def setUp(self):
35
- self.tmp = tempfile.TemporaryDirectory()
36
- self.addCleanup(self.tmp.cleanup)
37
- self.base = Path(self.tmp.name)
38
-
39
- def test_config_store_sets_and_clears_targeted_proxy(self):
40
- store = ConfigStore(self.base / "config.toml")
41
-
42
- store.set_proxy("xhs", "http://parse", "parse")
43
- store.set_proxy("xhs", "http://download", "download")
44
-
45
- config = store.get_platform("xhs")
46
- self.assertEqual(config.parse_proxy, "http://parse")
47
- self.assertEqual(config.download_proxy, "http://download")
48
-
49
- self.assertTrue(store.clear_proxy("xhs", "parse"))
50
- config = store.get_platform("xhs")
51
- self.assertIsNone(config.parse_proxy)
52
- self.assertEqual(config.download_proxy, "http://download")
53
-
54
- def test_auto_cookie_store_falls_back_to_private_file(self):
55
- path = self.base / "cookies.toml"
56
- store = AutoCookieStore(keyring_store=UnavailableKeyringStore(), file_store=FileCookieStore(path))
57
-
58
- storage = store.set("xhs", "a=b")
59
-
60
- self.assertEqual(storage, "file")
61
- self.assertEqual(store.get("xhs"), "a=b")
62
- self.assertEqual(path.stat().st_mode & 0o777, 0o600)
63
-
64
- def test_auto_cookie_store_prefers_keyring_and_removes_file_fallback(self):
65
- path = self.base / "cookies.toml"
66
- file_store = FileCookieStore(path)
67
- file_store.set("xhs", "old=file")
68
- keyring_store = MemoryKeyringStore()
69
- store = AutoCookieStore(keyring_store=keyring_store, file_store=file_store)
70
-
71
- storage = store.set("xhs", "new=keyring")
72
-
73
- self.assertEqual(storage, "keyring")
74
- self.assertEqual(store.get("xhs"), "new=keyring")
75
- self.assertFalse(file_store.exists("xhs"))
76
-
77
-
78
- if __name__ == "__main__":
79
- unittest.main()
File without changes
File without changes
File without changes
File without changes