ddns 4.1.0b3__tar.gz → 4.1.0b4__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.

Potentially problematic release.


This version of ddns might be problematic. Click here for more details.

Files changed (78) hide show
  1. {ddns-4.1.0b3 → ddns-4.1.0b4}/PKG-INFO +2 -2
  2. {ddns-4.1.0b3 → ddns-4.1.0b4}/README.md +1 -1
  3. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/__init__.py +2 -2
  4. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/__main__.py +12 -7
  5. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/ip.py +50 -4
  6. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/util/fileio.py +2 -2
  7. ddns-4.1.0b4/ddns/util/try_run.py +37 -0
  8. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns.egg-info/PKG-INFO +2 -2
  9. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns.egg-info/SOURCES.txt +2 -0
  10. ddns-4.1.0b4/tests/test_ip.py +239 -0
  11. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_scheduler_base.py +37 -9
  12. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_scheduler_cron.py +119 -35
  13. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_scheduler_init.py +16 -11
  14. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_scheduler_launchd.py +32 -26
  15. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_scheduler_schtasks.py +33 -34
  16. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_scheduler_systemd.py +34 -21
  17. {ddns-4.1.0b3 → ddns-4.1.0b4}/LICENSE +0 -0
  18. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/__builtins__.pyi +0 -0
  19. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/cache.py +0 -0
  20. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/__init__.py +0 -0
  21. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/_base.py +0 -0
  22. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/_signature.py +0 -0
  23. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/alidns.py +0 -0
  24. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/aliesa.py +0 -0
  25. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/callback.py +0 -0
  26. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/cloudflare.py +0 -0
  27. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/debug.py +0 -0
  28. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/dnscom.py +0 -0
  29. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/dnspod.py +0 -0
  30. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/dnspod_com.py +0 -0
  31. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/edgeone.py +0 -0
  32. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/he.py +0 -0
  33. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/huaweidns.py +0 -0
  34. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/namesilo.py +0 -0
  35. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/noip.py +0 -0
  36. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/provider/tencentcloud.py +0 -0
  37. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/util/__init__.py +0 -0
  38. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/util/comment.py +0 -0
  39. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns/util/http.py +0 -0
  40. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns.egg-info/dependency_links.txt +0 -0
  41. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns.egg-info/entry_points.txt +0 -0
  42. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns.egg-info/requires.txt +0 -0
  43. {ddns-4.1.0b3 → ddns-4.1.0b4}/ddns.egg-info/top_level.txt +0 -0
  44. {ddns-4.1.0b3 → ddns-4.1.0b4}/pyproject.toml +0 -0
  45. {ddns-4.1.0b3 → ddns-4.1.0b4}/setup.cfg +0 -0
  46. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_cache.py +0 -0
  47. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_config_cli.py +0 -0
  48. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_config_cli_task.py +0 -0
  49. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_config_config.py +0 -0
  50. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_config_env.py +0 -0
  51. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_config_file.py +0 -0
  52. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_config_file_remote.py +0 -0
  53. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_config_init.py +0 -0
  54. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_config_init_multi.py +0 -0
  55. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_config_schema_v4_1.py +0 -0
  56. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider__signature.py +0 -0
  57. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_alidns.py +0 -0
  58. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_aliesa.py +0 -0
  59. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_base.py +0 -0
  60. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_base_simple.py +0 -0
  61. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_callback.py +0 -0
  62. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_cloudflare.py +0 -0
  63. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_debug.py +0 -0
  64. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_dnscom.py +0 -0
  65. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_dnspod.py +0 -0
  66. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_dnspod_com.py +0 -0
  67. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_edgeone.py +0 -0
  68. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_he.py +0 -0
  69. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_huaweidns.py +0 -0
  70. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_namesilo.py +0 -0
  71. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_noip.py +0 -0
  72. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_proxy_list.py +0 -0
  73. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_provider_tencentcloud.py +0 -0
  74. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_util_comment.py +0 -0
  75. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_util_fileio.py +0 -0
  76. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_util_http.py +0 -0
  77. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_util_http_proxy_list.py +0 -0
  78. {ddns-4.1.0b3 → ddns-4.1.0b4}/tests/test_util_http_retry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ddns
3
- Version: 4.1.0b3
3
+ Version: 4.1.0b4
4
4
  Summary: Dynamic DNS client for multiple providers, supporting IPv4 and IPv6.
5
5
  Author-email: NewFuture <python@newfuture.cc>
6
6
  License-Expression: MIT
@@ -138,7 +138,7 @@ Dynamic: license-file
138
138
  也可使用一键安装脚本自动下载并安装对应平台的二进制:
139
139
 
140
140
  ```bash
141
- curl -fSL https://ddns.newfuture.cc/install.sh | sh
141
+ curl -#fSL https://ddns.newfuture.cc/install.sh | sh
142
142
  ```
143
143
  提示:安装到系统目录(如 /usr/local/bin)可能需要 root 或 sudo 权限;若权限不足,可改为 `sudo sh` 运行。
144
144
 
@@ -94,7 +94,7 @@
94
94
  也可使用一键安装脚本自动下载并安装对应平台的二进制:
95
95
 
96
96
  ```bash
97
- curl -fSL https://ddns.newfuture.cc/install.sh | sh
97
+ curl -#fSL https://ddns.newfuture.cc/install.sh | sh
98
98
  ```
99
99
  提示:安装到系统目录(如 /usr/local/bin)可能需要 root 或 sudo 权限;若权限不足,可改为 `sudo sh` 运行。
100
100
 
@@ -6,7 +6,7 @@ ddns Package
6
6
  __description__ = "automatically update DNS records to my IP [域名自动指向本机IP]"
7
7
 
8
8
  # 编译时,版本会被替换
9
- __version__ = "4.1.0b3"
9
+ __version__ = "4.1.0b4"
10
10
 
11
11
  # 时间也会被替换掉
12
- build_date = "2025-08-11T15:00:49Z"
12
+ build_date = "2025-08-16T02:41:26Z"
@@ -4,16 +4,16 @@ DDNS
4
4
  @author: NewFuture, rufengsuixing
5
5
  """
6
6
 
7
+ import sys
7
8
  from io import TextIOWrapper
8
- from subprocess import check_output
9
9
  from logging import getLogger
10
- import sys
10
+ from subprocess import check_output
11
11
 
12
- from .__init__ import __version__, __description__, build_date
13
- from .config import load_configs, Config # noqa: F401
14
- from .provider import get_provider_class, SimpleProvider # noqa: F401
15
12
  from . import ip
13
+ from .__init__ import __description__, __version__, build_date
16
14
  from .cache import Cache
15
+ from .config import Config, load_configs # noqa: F401
16
+ from .provider import SimpleProvider, get_provider_class # noqa: F401
17
17
 
18
18
  logger = getLogger()
19
19
 
@@ -102,11 +102,16 @@ def run(config):
102
102
 
103
103
 
104
104
  def main():
105
- encode = sys.stdout.encoding
106
- if encode is not None and encode.lower() != "utf-8" and hasattr(sys.stdout, "buffer"):
105
+ stdout = sys.stdout # pythonw 模式无 stdout
106
+ if stdout and stdout.encoding and stdout.encoding.lower() != "utf-8" and hasattr(stdout, "buffer"):
107
107
  # 兼容windows 和部分ASCII编码的老旧系统
108
108
  sys.stdout = TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
109
109
  sys.stderr = TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
110
+
111
+ # Windows 下输出一个空行
112
+ if stdout and sys.platform.startswith("win"):
113
+ stdout.write("\r\n")
114
+
110
115
  logger.name = "ddns"
111
116
 
112
117
  # 使用多配置加载器,它会自动处理单个和多个配置
@@ -16,6 +16,24 @@ IPV4_REG = r"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1
16
16
  # https://community.helpsystems.com/forums/intermapper/miscellaneous-topics/5acc4fcf-fa83-e511-80cf-0050568460e4
17
17
  IPV6_REG = r"((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))" # noqa: E501
18
18
 
19
+ # 公网IPv4 API列表,按优先级排序
20
+ PUBLIC_IPV4_APIS = [
21
+ "https://api.ipify.cn",
22
+ "https://api.ipify.org",
23
+ "https://4.ipw.cn/",
24
+ "https://ipinfo.io/ip",
25
+ "https://api-ipv4.ip.sb/ip",
26
+ "http://checkip.amazonaws.com",
27
+ ]
28
+
29
+ # 公网IPv6 API列表,按优先级排序
30
+ PUBLIC_IPV6_APIS = [
31
+ "https://api6.ipify.org/",
32
+ "https://6.ipw.cn/",
33
+ "https://api-ipv6.ip.sb/ip",
34
+ "http://ipv6.icanhazip.com",
35
+ ]
36
+
19
37
 
20
38
  def default_v4(): # 默认连接外网的ipv4
21
39
  s = socket(AF_INET, SOCK_DGRAM)
@@ -60,12 +78,40 @@ def _open(url, reg):
60
78
  error(e)
61
79
 
62
80
 
63
- def public_v4(url="https://api-ipv4.ip.sb/ip", reg=IPV4_REG): # 公网IPV4地址
64
- return _open(url, reg)
81
+ def _try_multiple_apis(api_list, reg, ip_type):
82
+ """
83
+ Try multiple API endpoints until one succeeds
84
+ """
85
+ for url in api_list:
86
+ try:
87
+ debug("Trying %s API: %s", ip_type, url)
88
+ result = _open(url, reg)
89
+ if result:
90
+ debug("Successfully got %s from %s: %s", ip_type, url, result)
91
+ return result
92
+ else:
93
+ debug("No valid IP found from %s", url)
94
+ except Exception as e:
95
+ debug("Failed to get %s from %s: %s", ip_type, url, e)
96
+ return None
97
+
98
+
99
+ def public_v4(url=None, reg=IPV4_REG): # 公网IPV4地址
100
+ if url:
101
+ # 使用指定URL
102
+ return _open(url, reg)
103
+ else:
104
+ # 使用多个API自动重试
105
+ return _try_multiple_apis(PUBLIC_IPV4_APIS, reg, "IPv4")
65
106
 
66
107
 
67
- def public_v6(url="https://api-ipv6.ip.sb/ip", reg=IPV6_REG): # 公网IPV6地址
68
- return _open(url, reg)
108
+ def public_v6(url=None, reg=IPV6_REG): # 公网IPV6地址
109
+ if url:
110
+ # 使用指定URL
111
+ return _open(url, reg)
112
+ else:
113
+ # 使用多个API自动重试
114
+ return _try_multiple_apis(PUBLIC_IPV6_APIS, reg, "IPv6")
69
115
 
70
116
 
71
117
  def _ip_regex_match(parrent_regex, match_regex):
@@ -23,7 +23,7 @@ def _ensure_directory_exists(file_path): # type: (str) -> None
23
23
  os.makedirs(directory)
24
24
 
25
25
 
26
- def read_file_safely(file_path, encoding="utf-8", default=None): # type: (str, str, str|None) -> str | None
26
+ def read_file_safely(file_path, encoding="utf-8", default=None): # type: (str, str, str|None) -> str
27
27
  """
28
28
  Safely read file content with UTF-8 encoding, return None if file doesn't exist or can't be read
29
29
 
@@ -37,7 +37,7 @@ def read_file_safely(file_path, encoding="utf-8", default=None): # type: (str,
37
37
  try:
38
38
  return read_file(file_path, encoding)
39
39
  except Exception:
40
- return default
40
+ return default # type: ignore
41
41
 
42
42
 
43
43
  def write_file_safely(file_path, content, encoding="utf-8"): # type: (str, str, str) -> bool
@@ -0,0 +1,37 @@
1
+ # -*- coding:utf-8 -*-
2
+ """
3
+ Utility: Safe command execution wrapper used across the project.
4
+ Provides a single try_run function with consistent behavior.
5
+ """
6
+
7
+ import subprocess
8
+ import sys
9
+
10
+
11
+ def try_run(command, logger=None, **kwargs):
12
+ # type: (list, object, **object) -> str | None
13
+ """Safely run a subprocess command and return decoded output or None on failure.
14
+
15
+ Args:
16
+ command (list): Command array to execute
17
+ logger (object, optional): Logger instance for debug output
18
+ **kwargs: Additional arguments passed to subprocess.check_output
19
+
20
+ Returns:
21
+ str or None: Command output as string, or None if command failed
22
+
23
+ - Adds a default timeout=60s on Python 3 to avoid hangs
24
+ - Decodes output as text via universal_newlines=True
25
+ - Logs at debug level when logger is provided
26
+ """
27
+ try:
28
+ if sys.version_info[0] >= 3 and "timeout" not in kwargs:
29
+ kwargs["timeout"] = 60
30
+ return subprocess.check_output(command, universal_newlines=True, **kwargs) # type: ignore
31
+ except Exception as e: # noqa: BLE001 - broad for subprocess safety
32
+ if logger is not None:
33
+ try:
34
+ logger.debug("Command failed: %s", e) # type: ignore
35
+ except Exception:
36
+ pass
37
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ddns
3
- Version: 4.1.0b3
3
+ Version: 4.1.0b4
4
4
  Summary: Dynamic DNS client for multiple providers, supporting IPv4 and IPv6.
5
5
  Author-email: NewFuture <python@newfuture.cc>
6
6
  License-Expression: MIT
@@ -138,7 +138,7 @@ Dynamic: license-file
138
138
  也可使用一键安装脚本自动下载并安装对应平台的二进制:
139
139
 
140
140
  ```bash
141
- curl -fSL https://ddns.newfuture.cc/install.sh | sh
141
+ curl -#fSL https://ddns.newfuture.cc/install.sh | sh
142
142
  ```
143
143
  提示:安装到系统目录(如 /usr/local/bin)可能需要 root 或 sudo 权限;若权限不足,可改为 `sudo sh` 运行。
144
144
 
@@ -34,6 +34,7 @@ ddns/util/__init__.py
34
34
  ddns/util/comment.py
35
35
  ddns/util/fileio.py
36
36
  ddns/util/http.py
37
+ ddns/util/try_run.py
37
38
  tests/test_cache.py
38
39
  tests/test_config_cli.py
39
40
  tests/test_config_cli_task.py
@@ -44,6 +45,7 @@ tests/test_config_file_remote.py
44
45
  tests/test_config_init.py
45
46
  tests/test_config_init_multi.py
46
47
  tests/test_config_schema_v4_1.py
48
+ tests/test_ip.py
47
49
  tests/test_provider__signature.py
48
50
  tests/test_provider_alidns.py
49
51
  tests/test_provider_aliesa.py
@@ -0,0 +1,239 @@
1
+ # -*- coding:utf-8 -*-
2
+ """
3
+ Tests for ddns.ip module including integration tests
4
+ """
5
+ from __init__ import unittest, patch, MagicMock
6
+ from ddns import ip
7
+ from ddns.__main__ import get_ip
8
+ from ddns.util.http import HttpResponse
9
+
10
+
11
+ class TestIpModule(unittest.TestCase):
12
+ """测试IP获取模块"""
13
+
14
+ def setUp(self):
15
+ """设置测试环境"""
16
+ self.original_ssl_verify = ip.ssl_verify
17
+
18
+ def tearDown(self):
19
+ """清理测试环境"""
20
+ ip.ssl_verify = self.original_ssl_verify
21
+
22
+ @patch('ddns.ip.request')
23
+ def test_url_v4_success(self, mock_request):
24
+ """测试自定义URL获取IPv4 - 成功"""
25
+ # 模拟成功响应
26
+ mock_response = MagicMock()
27
+ mock_response.body = "1.2.3.4"
28
+ mock_request.return_value = mock_response
29
+
30
+ result = ip.public_v4("https://test.example.com/ip")
31
+
32
+ self.assertEqual(result, "1.2.3.4")
33
+ mock_request.assert_called_once_with("GET", "https://test.example.com/ip", verify=ip.ssl_verify, retries=2)
34
+
35
+ @patch('ddns.ip.request')
36
+ def test_url_v6_success(self, mock_request):
37
+ """测试自定义URL获取IPv6 - 成功"""
38
+ # 模拟成功响应
39
+ mock_response = MagicMock()
40
+ mock_response.body = "2001:db8::1"
41
+ mock_request.return_value = mock_response
42
+
43
+ result = ip.public_v6("https://test.example.com/ipv6")
44
+
45
+ self.assertEqual(result, "2001:db8::1")
46
+ mock_request.assert_called_once_with("GET", "https://test.example.com/ipv6", verify=ip.ssl_verify, retries=2)
47
+
48
+ @patch('ddns.ip.request')
49
+ def test_url_v4_request_failure(self, mock_request):
50
+ """测试自定义URL获取IPv4 - 请求失败"""
51
+ # 模拟请求异常
52
+ mock_request.side_effect = Exception("Network error")
53
+
54
+ result = ip.public_v4("https://test.example.com/ip")
55
+
56
+ self.assertIsNone(result)
57
+ mock_request.assert_called_once_with("GET", "https://test.example.com/ip", verify=ip.ssl_verify, retries=2)
58
+
59
+ @patch('ddns.ip.request')
60
+ def test_url_v4_invalid_response(self, mock_request):
61
+ """测试自定义URL获取IPv4 - 无效响应"""
62
+ # 模拟无效响应
63
+ mock_response = MagicMock()
64
+ mock_response.body = "invalid response"
65
+ mock_request.return_value = mock_response
66
+
67
+ result = ip.public_v4("https://test.example.com/ip")
68
+
69
+ self.assertIsNone(result)
70
+ mock_request.assert_called_once_with("GET", "https://test.example.com/ip", verify=ip.ssl_verify, retries=2)
71
+
72
+ @patch('ddns.ip.request')
73
+ def test_public_v4_multiple_apis_first_success(self, mock_request):
74
+ """测试公网IPv4获取 - 多个API第一个成功"""
75
+ # 模拟第一个API成功
76
+ mock_response = MagicMock()
77
+ mock_response.body = "1.2.3.4"
78
+ mock_request.return_value = mock_response
79
+
80
+ result = ip.public_v4()
81
+
82
+ self.assertEqual(result, "1.2.3.4")
83
+ # 应该只调用第一个API
84
+ mock_request.assert_called_once_with("GET", ip.PUBLIC_IPV4_APIS[0], verify=ip.ssl_verify, retries=2)
85
+
86
+ @patch('ddns.ip.request')
87
+ def test_public_v4_multiple_apis_fallback_success(self, mock_request):
88
+ """测试公网IPv4获取 - 多个API第一个失败第二个成功"""
89
+ def mock_request_side_effect(method, url, **kwargs):
90
+ if url == ip.PUBLIC_IPV4_APIS[0]:
91
+ raise Exception("First API failed")
92
+ else:
93
+ mock_response = MagicMock()
94
+ mock_response.body = "1.2.3.4"
95
+ return mock_response
96
+
97
+ mock_request.side_effect = mock_request_side_effect
98
+
99
+ result = ip.public_v4()
100
+
101
+ self.assertEqual(result, "1.2.3.4")
102
+ # 应该调用前两个API
103
+ self.assertEqual(mock_request.call_count, 2)
104
+ mock_request.assert_any_call("GET", ip.PUBLIC_IPV4_APIS[0], verify=ip.ssl_verify, retries=2)
105
+ mock_request.assert_any_call("GET", ip.PUBLIC_IPV4_APIS[1], verify=ip.ssl_verify, retries=2)
106
+
107
+ @patch('ddns.ip.request')
108
+ def test_public_v4_multiple_apis_all_fail(self, mock_request):
109
+ """测试公网IPv4获取 - 多个API全部失败"""
110
+ # 模拟所有API都失败
111
+ mock_request.side_effect = Exception("All APIs failed")
112
+
113
+ result = ip.public_v4()
114
+
115
+ self.assertIsNone(result)
116
+ # 应该调用所有API
117
+ self.assertEqual(mock_request.call_count, len(ip.PUBLIC_IPV4_APIS))
118
+
119
+ @patch('ddns.ip.request')
120
+ def test_public_v6_multiple_apis_first_success(self, mock_request):
121
+ """测试公网IPv6获取 - 多个API第一个成功"""
122
+ # 模拟第一个API成功
123
+ mock_response = MagicMock()
124
+ mock_response.body = "2001:db8::1"
125
+ mock_request.return_value = mock_response
126
+
127
+ result = ip.public_v6()
128
+
129
+ self.assertEqual(result, "2001:db8::1")
130
+ # 应该只调用第一个API
131
+ mock_request.assert_called_once_with("GET", ip.PUBLIC_IPV6_APIS[0], verify=ip.ssl_verify, retries=2)
132
+
133
+ @patch('ddns.ip.request')
134
+ def test_public_v6_multiple_apis_fallback_success(self, mock_request):
135
+ """测试公网IPv6获取 - 多个API第一个失败第二个成功"""
136
+ def mock_request_side_effect(method, url, **kwargs):
137
+ if url == ip.PUBLIC_IPV6_APIS[0]:
138
+ raise Exception("First API failed")
139
+ else:
140
+ mock_response = MagicMock()
141
+ mock_response.body = "2001:db8::1"
142
+ return mock_response
143
+
144
+ mock_request.side_effect = mock_request_side_effect
145
+
146
+ result = ip.public_v6()
147
+
148
+ self.assertEqual(result, "2001:db8::1")
149
+ # 应该调用前两个API
150
+ self.assertEqual(mock_request.call_count, 2)
151
+ mock_request.assert_any_call("GET", ip.PUBLIC_IPV6_APIS[0], verify=ip.ssl_verify, retries=2)
152
+ mock_request.assert_any_call("GET", ip.PUBLIC_IPV6_APIS[1], verify=ip.ssl_verify, retries=2)
153
+
154
+ def test_public_ipv4_apis_list_exists(self):
155
+ """测试IPv4 API列表存在并包含所需的API"""
156
+ expected_apis = [
157
+ "https://api.ipify.cn",
158
+ "https://api.ipify.org",
159
+ "https://4.ipw.cn/",
160
+ "https://ipinfo.io/ip",
161
+ "https://api-ipv4.ip.sb/ip",
162
+ "http://checkip.amazonaws.com",
163
+ ]
164
+ self.assertEqual(ip.PUBLIC_IPV4_APIS, expected_apis)
165
+
166
+ def test_public_ipv6_apis_list_exists(self):
167
+ """测试IPv6 API列表存在并包含所需的API"""
168
+ expected_apis = [
169
+ "https://api6.ipify.org/",
170
+ "https://6.ipw.cn/",
171
+ "https://api-ipv6.ip.sb/ip",
172
+ "http://ipv6.icanhazip.com",
173
+ ]
174
+ self.assertEqual(ip.PUBLIC_IPV6_APIS, expected_apis)
175
+
176
+ @patch('ddns.ip.request')
177
+ def test_get_ip_public_mode_fallback(self, mock_request):
178
+ """测试通过get_ip使用public模式的自动fallback功能"""
179
+ # 模拟第一个API失败,第二个成功
180
+ def mock_request_side_effect(method, url, **kwargs):
181
+ if "api.ipify.cn" in url:
182
+ raise Exception("First API failed")
183
+ elif "api.ipify.org" in url:
184
+ mock_response = MagicMock()
185
+ mock_response.body = "1.2.3.4"
186
+ return mock_response
187
+ else:
188
+ raise Exception("Unexpected URL")
189
+
190
+ mock_request.side_effect = mock_request_side_effect
191
+
192
+ # 使用"public"规则获取IPv4地址
193
+ result = get_ip("4", ["public"])
194
+
195
+ self.assertEqual(result, "1.2.3.4")
196
+ # 应该调用了前两个API
197
+ self.assertEqual(mock_request.call_count, 2)
198
+
199
+ @patch('ddns.ip.request')
200
+ def test_get_ip_url_mode_backward_compatibility(self, mock_request):
201
+ """测试通过get_ip使用url:模式的向后兼容性"""
202
+ # 模拟成功响应
203
+ mock_response = MagicMock()
204
+ mock_response.body = "1.2.3.4"
205
+ mock_request.return_value = mock_response
206
+
207
+ # 使用"url:"规则获取IPv4地址
208
+ result = get_ip("4", ["url:https://custom.api.com/ip"])
209
+
210
+ self.assertEqual(result, "1.2.3.4")
211
+ # 应该只调用指定的API
212
+ mock_request.assert_called_once()
213
+ args, kwargs = mock_request.call_args
214
+ self.assertEqual(args[1], "https://custom.api.com/ip")
215
+
216
+ @patch('ddns.ip.request')
217
+ def test_get_ip_multiple_rules_limitation(self, mock_request):
218
+ """测试get_ip的多规则限制 - 当前实现的限制"""
219
+ # 注意:当前get_ip实现有限制 - 如果一个规则返回None,它不会尝试下一个规则
220
+ # 只有当规则抛出异常时才会尝试下一个规则
221
+ # 这是一个已知限制,不在本次功能实现范围内
222
+
223
+ # 模拟所有public API都失败(返回无效响应)
224
+ mock_response = MagicMock()
225
+ mock_response.body = "invalid response"
226
+ mock_request.return_value = mock_response
227
+
228
+ # 使用多个规则:先尝试public,失败后应该尝试url:指定的API
229
+ # 但由于当前实现限制,public返回None后不会尝试下一个规则
230
+ result = get_ip("4", ["public", "url:https://backup.api.com/ip"])
231
+
232
+ # 由于当前实现限制,返回None而不是继续尝试backup API
233
+ self.assertIsNone(result)
234
+ # 只调用了public APIs,没有调用backup API
235
+ self.assertEqual(mock_request.call_count, len(ip.PUBLIC_IPV4_APIS))
236
+
237
+
238
+ if __name__ == '__main__':
239
+ unittest.main()
@@ -4,7 +4,8 @@ Unit tests for ddns.scheduler._base module
4
4
  @author: NewFuture
5
5
  """
6
6
 
7
- from __init__ import unittest, patch
7
+ from __init__ import patch, unittest
8
+
8
9
  from ddns.scheduler._base import BaseScheduler
9
10
 
10
11
 
@@ -58,8 +59,10 @@ class TestBaseScheduler(unittest.TestCase):
58
59
 
59
60
  command = self.scheduler._build_ddns_command(ddns_args)
60
61
 
61
- self.assertIsInstance(command, str)
62
- self.assertIn("python", command.lower())
62
+ self.assertIsInstance(command, list)
63
+ command_str = " ".join(command)
64
+ self.assertIn("python", command_str.lower())
65
+ self.assertIn("-m", command)
63
66
  self.assertIn("ddns", command)
64
67
  self.assertIn("--dns", command)
65
68
  self.assertIn("debug", command)
@@ -98,8 +101,9 @@ class TestBaseScheduler(unittest.TestCase):
98
101
 
99
102
  command = self.scheduler._build_ddns_command(ddns_args)
100
103
 
101
- self.assertIn("--debug true", command)
102
- self.assertIn("--cache true", command)
104
+ self.assertIn("--debug", command)
105
+ self.assertIn("true", command)
106
+ self.assertIn("--cache", command)
103
107
 
104
108
  def test_build_ddns_command_filters_debug_false(self):
105
109
  """Test _build_ddns_command filters out debug=False"""
@@ -107,8 +111,10 @@ class TestBaseScheduler(unittest.TestCase):
107
111
 
108
112
  command = self.scheduler._build_ddns_command(ddns_args)
109
113
 
110
- self.assertNotIn("--debug false", command)
111
- self.assertIn("--cache true", command)
114
+ command_str = " ".join(command)
115
+ self.assertNotIn("--debug false", command_str)
116
+ self.assertIn("--cache", command)
117
+ self.assertIn("true", command)
112
118
 
113
119
  def test_build_ddns_command_with_single_values(self):
114
120
  """Test _build_ddns_command with single value arguments"""
@@ -153,7 +159,8 @@ class TestBaseScheduler(unittest.TestCase):
153
159
  command = self.scheduler._build_ddns_command(ddns_args)
154
160
 
155
161
  # Should use sys.executable for Python path
156
- self.assertIn("python", command.lower())
162
+ command_str = " ".join(command)
163
+ self.assertIn("python", command_str.lower())
157
164
 
158
165
  def test_build_ddns_command_with_special_characters(self):
159
166
  """Test _build_ddns_command handles special characters"""
@@ -161,7 +168,7 @@ class TestBaseScheduler(unittest.TestCase):
161
168
 
162
169
  command = self.scheduler._build_ddns_command(ddns_args)
163
170
 
164
- self.assertIsInstance(command, str)
171
+ self.assertIsInstance(command, list)
165
172
  self.assertIn("test-domain.example.com", command)
166
173
  self.assertIn("test_token_with_special_chars!@#", command)
167
174
 
@@ -192,6 +199,27 @@ class TestBaseScheduler(unittest.TestCase):
192
199
  result = self.scheduler.disable()
193
200
  self.assertIsInstance(result, bool)
194
201
 
202
+ def test_quote_command_array(self):
203
+ """Test _quote_command_array method"""
204
+ # Test basic functionality
205
+ cmd_array = ["python", "script.py"]
206
+ result = self.scheduler._quote_command_array(cmd_array)
207
+ self.assertEqual(result, "python script.py")
208
+
209
+ # Test with spaces
210
+ cmd_array = ["python", "script with spaces.py", "normal_arg"]
211
+ result = self.scheduler._quote_command_array(cmd_array)
212
+ self.assertEqual(result, 'python "script with spaces.py" normal_arg')
213
+
214
+ # Test with multiple spaced arguments
215
+ cmd_array = ["python", "-m", "ddns", "--config", "config file.json"]
216
+ result = self.scheduler._quote_command_array(cmd_array)
217
+ self.assertEqual(result, 'python -m ddns --config "config file.json"')
218
+
219
+ # Test empty array
220
+ result = self.scheduler._quote_command_array([])
221
+ self.assertEqual(result, "")
222
+
195
223
 
196
224
  if __name__ == "__main__":
197
225
  unittest.main()