ddns 4.1.0b4__py2.py3-none-any.whl → 4.1.1__py2.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.
ddns/config/config.py ADDED
@@ -0,0 +1,214 @@
1
+ # coding=utf-8
2
+ """
3
+ Configuration class merged from CLI, JSON, and environment variables.
4
+ @author: NewFuture
5
+ """
6
+
7
+ from hashlib import md5
8
+ from .cli import str_bool, log_level as get_log_level
9
+
10
+ __all__ = ["Config", "split_array_string"]
11
+
12
+ # 简单数组,支持',', ';' 分隔的参数列表
13
+ SIMPLE_ARRAY_PARAMS = ["ipv4", "ipv6", "proxy", "index4", "index6"]
14
+
15
+
16
+ def is_false(value):
17
+ """
18
+ 判断值是否为 False
19
+ 字符串 'false', 或者 False, 或者 'none';
20
+ 0 不是 False
21
+ """
22
+ if hasattr(value, "strip"): # 字符串
23
+ return value.strip().lower() in ["false", "none"]
24
+ return value is False
25
+
26
+
27
+ def split_array_string(value):
28
+ # type: (str|list) -> list
29
+ """
30
+ 解析数组字符串
31
+ 逐个分解,遇到特殊前缀时停止分割
32
+ """
33
+ if isinstance(value, list):
34
+ return value
35
+ if not value or not hasattr(value, "strip"):
36
+ return [value] if value else []
37
+
38
+ trimmed = value.strip()
39
+
40
+ # 选择分隔符(逗号优先)
41
+ sep = "," if "," in trimmed else (";" if ";" in trimmed else None)
42
+ if not sep:
43
+ return [trimmed]
44
+
45
+ # 逐个分解,遇到特殊前缀时停止
46
+ parts = []
47
+ split_parts = trimmed.split(sep)
48
+ for i, part in enumerate(split_parts):
49
+ part = part.strip()
50
+ if not part:
51
+ continue
52
+
53
+ # 检查是否包含特殊前缀,如果有则合并剩余部分
54
+ if any(prefix in part for prefix in ["regex:", "cmd:", "shell:"]):
55
+ parts.append(sep.join(split_parts[i:]).strip())
56
+ break
57
+ parts.append(part)
58
+
59
+ return parts
60
+
61
+
62
+ class Config(object):
63
+ """
64
+ Configuration class for DDNS.
65
+ This class is used to load and manage configuration settings.
66
+ """
67
+
68
+ def __init__(self, cli_config=None, json_config=None, env_config=None):
69
+ # type: (dict | None, dict | None, dict | None) -> None
70
+ """
71
+ Initialize the Config object.
72
+ """
73
+ self._cli_config = cli_config or {}
74
+ self._json_config = json_config or {}
75
+ self._env_config = env_config or {}
76
+
77
+ # Known configuration keys that should not go into extra
78
+ self._known_keys = {
79
+ "dns",
80
+ "id",
81
+ "token",
82
+ "endpoint",
83
+ "index4",
84
+ "index6",
85
+ "ipv4",
86
+ "ipv6",
87
+ "ttl",
88
+ "line",
89
+ "proxy",
90
+ "cache",
91
+ "ssl",
92
+ "log_level",
93
+ "log_format",
94
+ "log_file",
95
+ "log_datefmt",
96
+ "extra",
97
+ "debug",
98
+ "config",
99
+ "command",
100
+ }
101
+
102
+ # dns related configurations
103
+ self.dns = self._get("dns", "") # type: str
104
+ self.id = self._get("id", "") # type: str
105
+ self.token = self._get("token", "") # type: str
106
+ self.endpoint = self._get("endpoint") # type: str | None
107
+ self.index4 = self._get("index4", ["default"]) # type: list[str]|Literal[False]
108
+ self.index6 = self._get("index6", ["default"]) # type: list[str]|Literal[False]
109
+ self.ipv4 = self._get("ipv4", []) # type: list[str]
110
+ self.ipv6 = self._get("ipv6", []) # type: list[str]
111
+ ttl = self._get("ttl", None) # type: int | str | None
112
+ self.ttl = int(ttl) if isinstance(ttl, (str, bytes)) else ttl # type: int | None
113
+ self.line = self._get("line", None) # type: str | None
114
+ self.proxy = self._get("proxy", []) # type: list[str] | None
115
+ # cache and SSL settings
116
+ self.cache = str_bool(self._get("cache", True))
117
+ self.ssl = str_bool(self._get("ssl", "auto"))
118
+
119
+ log_level = self._get("log_level", "INFO")
120
+ if isinstance(log_level, (str, bytes)):
121
+ log_level = get_log_level(log_level)
122
+ self.log_level = log_level # type: int # type: ignore[assignment]
123
+ self.log_format = self._get("log_format", None) # type: str | None
124
+ self.log_file = self._get("log_file", None) # type: str | None
125
+ self.log_datefmt = self._get("log_datefmt", "%Y-%m-%dT%H:%M:%S") # type: str | None
126
+
127
+ # Collect extra fields from all config sources
128
+ self.extra = self._collect_extra() # type: dict
129
+
130
+ def _get(self, key, default=None):
131
+ # type: (str, Any) -> Any
132
+ """
133
+ Get a configuration value by key.
134
+ """
135
+ value = self._cli_config.get(key)
136
+ if isinstance(value, list) and len(value) == 1:
137
+ # 如果是单个元素的列表,取出第一个元素, 这样可以避免在 CLI 配置中使用单个值时仍然得到一个列表
138
+ value = value[0]
139
+ if value is None:
140
+ value = self._json_config.get(key, self._env_config.get(key, default))
141
+ if is_false(value):
142
+ return False
143
+ # 处理数组参数
144
+ if key in SIMPLE_ARRAY_PARAMS:
145
+ return split_array_string(value)
146
+ return value
147
+
148
+ def _collect_extra(self):
149
+ # type: () -> dict
150
+ """
151
+ Collect all extra fields from CLI, JSON, and ENV configs that are not known keys.
152
+ Priority: CLI > JSON > ENV
153
+ """
154
+ extra = {} # type: dict
155
+
156
+ # Collect from env config first (lowest priority)
157
+ for key, value in self._env_config.items():
158
+ if key.startswith("extra_"):
159
+ extra_key = key[6:] # Remove "extra_" prefix
160
+ extra[extra_key] = value
161
+ elif key == "extra" and isinstance(value, dict):
162
+ extra.update(value)
163
+ elif key not in self._known_keys:
164
+ extra[key] = value
165
+
166
+ # Collect from JSON config (medium priority)
167
+ for key, value in self._json_config.items():
168
+ if key == "extra" and isinstance(value, dict):
169
+ extra.update(value)
170
+ elif key not in self._known_keys:
171
+ extra[key] = value
172
+
173
+ # Collect from CLI config (highest priority)
174
+ for key, value in self._cli_config.items():
175
+ if key.startswith("extra_"):
176
+ extra_key = key[6:] # Remove "extra_" prefix
177
+ extra[extra_key] = value
178
+ elif key not in self._known_keys:
179
+ extra[key] = value
180
+
181
+ return extra
182
+
183
+ def md5(self):
184
+ # type: () -> str
185
+ """
186
+ Generate hash based on all merged configurations.
187
+
188
+ Returns:
189
+ str: Hash value based on configuration attributes.
190
+ """
191
+ dict_var = {
192
+ "dns": self.dns,
193
+ "id": self.id,
194
+ "token": self.token,
195
+ "endpoint": self.endpoint,
196
+ "index4": self.index4,
197
+ "index6": self.index6,
198
+ "ipv4": self.ipv4,
199
+ "ipv6": self.ipv6,
200
+ "line": self.line,
201
+ "ttl": self.ttl,
202
+ # System settings
203
+ "cache": self.cache,
204
+ "proxy": self.proxy,
205
+ "ssl": self.ssl,
206
+ # Logging settings
207
+ "log_level": self.log_level,
208
+ "log_format": self.log_format,
209
+ "log_file": self.log_file,
210
+ "log_datefmt": self.log_datefmt,
211
+ # Extra fields
212
+ "extra": self.extra,
213
+ }
214
+ return md5(str(dict_var).encode("utf-8")).hexdigest()
ddns/config/env.py ADDED
@@ -0,0 +1,77 @@
1
+ # -*- coding:utf-8 -*-
2
+ """
3
+ Configuration loader for DDNS environment variables.
4
+ @author: NewFuture
5
+ """
6
+
7
+ from ast import literal_eval
8
+ from os import environ
9
+ from sys import stderr
10
+
11
+ __all__ = ["load_config"]
12
+
13
+
14
+ def _try_parse_array(value, key=None):
15
+ # type: (str, str|None) -> Any
16
+ """解析数组值[], 非数组返回元素字符串(去除前后空格)"""
17
+ if not value:
18
+ return value
19
+
20
+ value = value.strip()
21
+ # 尝试解析 JSON
22
+ # if (value.startswith("{'") and value.endswith("'}")) or (value.startswith('{"') and value.endswith('"}')):
23
+ # try:
24
+ # return json_decode(value)
25
+ # except Exception:
26
+ # logging.warning("Failed to parse JSON from value: %s", value)
27
+ if value.startswith("[") and value.endswith("]"):
28
+ # or (value.startswith("{") and value.endswith("}"))
29
+ try:
30
+ return literal_eval(value)
31
+ except Exception:
32
+ stderr.write("Failed to parse JSON array from value: {}={}\n".format(key, value))
33
+ pass
34
+ # 返回去除前后空格的字符串
35
+ return value
36
+
37
+
38
+ def load_config(prefix="DDNS_"):
39
+ # type: (str) -> dict
40
+ """
41
+ 从环境变量加载配置并返回配置字典。
42
+
43
+ 支持以下转换:
44
+ 1. 对于特定的数组参数(index4, index6, ipv4, ipv6, proxy),转换为数组
45
+ 2. 对于 JSON 格式的数组 [item1,item2],转换为数组
46
+ 3. 键名转换:点号转下划线,支持大小写变体
47
+ 4. 自动检测标准 Python 环境变量:
48
+ - SSL 验证:PYTHONHTTPSVERIFY
49
+ 5. 支持 extra 字段:DDNS_EXTRA_XXX 会被转换为 extra_xxx
50
+ 6. 其他所有值保持原始字符串格式,去除前后空格
51
+
52
+ Args:
53
+ prefix (str): 环境变量前缀,默认为 "DDNS_"
54
+
55
+ Returns:
56
+ dict: 从环境变量解析的配置字典
57
+ """
58
+ env_vars = {} # type: dict[str, str | list]
59
+
60
+ # 标准环境变量映射
61
+ alias_mappings = {"pythonhttpsverify": "ssl"}
62
+
63
+ for key, value in environ.items():
64
+ lower_key = key.lower()
65
+ config_key = None
66
+
67
+ if lower_key in alias_mappings:
68
+ config_key = alias_mappings[lower_key]
69
+ if config_key in env_vars:
70
+ continue # DDNS变量优先级更高
71
+ elif lower_key.startswith(prefix.lower()):
72
+ config_key = lower_key[len(prefix) :].replace(".", "_") # noqa: E203
73
+
74
+ if config_key:
75
+ env_vars[config_key] = _try_parse_array(value, key=key)
76
+
77
+ return env_vars
ddns/config/file.py ADDED
@@ -0,0 +1,169 @@
1
+ # -*- coding:utf-8 -*-
2
+ """
3
+ Configuration file loader for DDNS. supports both JSON and AST parsing.
4
+ @author: NewFuture
5
+ """
6
+
7
+ from ast import literal_eval
8
+ from json import loads as json_decode, dumps as json_encode
9
+ from sys import stderr, stdout
10
+ from ..util.comment import remove_comment
11
+ from ..util.http import request
12
+ from ..util.fileio import read_file, write_file
13
+
14
+
15
+ def _process_multi_providers(config):
16
+ # type: (dict) -> list[dict]
17
+ """Process v4.1 providers format and return list of configs."""
18
+ result = []
19
+
20
+ # 提取全局配置(除providers之外的所有配置)
21
+ global_config = _flatten_single_config(config, exclude_keys=["providers"])
22
+
23
+ # 检查providers和dns字段不能同时使用
24
+ if global_config.get("dns"):
25
+ stderr.write("Error: 'providers' and 'dns' fields cannot be used simultaneously in config file!\n")
26
+ raise ValueError("providers and dns fields conflict")
27
+
28
+ # 为每个provider创建独立配置
29
+ for provider_config in config["providers"]:
30
+ # 验证provider必须有provider字段
31
+ if not provider_config.get("provider"):
32
+ stderr.write("Error: Each provider must have a 'provider' field!\n")
33
+ raise ValueError("provider missing provider field")
34
+
35
+ flat_config = global_config.copy() # 从全局配置开始
36
+ provider_flat = _flatten_single_config(provider_config, exclude_keys=["provider"])
37
+ flat_config["dns"] = provider_config.get("provider")
38
+ flat_config.update(provider_flat)
39
+ result.append(flat_config)
40
+ return result
41
+
42
+
43
+ def _flatten_single_config(config, exclude_keys=None):
44
+ # type: (dict, list[str]|None) -> dict
45
+ """Flatten a single config object with optional key exclusion."""
46
+ if exclude_keys is None:
47
+ exclude_keys = []
48
+ flat_config = {}
49
+ for k, v in config.items():
50
+ if k in exclude_keys:
51
+ continue
52
+ if isinstance(v, dict):
53
+ for subk, subv in v.items():
54
+ flat_config["{}_{}".format(k, subk)] = subv
55
+ else:
56
+ flat_config[k] = v
57
+ return flat_config
58
+
59
+
60
+ def load_config(config_path, proxy=None, ssl="auto"):
61
+ # type: (str, list[str] | None, bool | str) -> dict|list[dict]
62
+ """
63
+ 加载配置文件并返回配置字典或配置字典数组。
64
+ 支持本地文件和远程HTTP(S) URL。
65
+ 对于单个对象返回dict,对于数组返回list[dict]。
66
+ 优先尝试JSON解析,失败后尝试AST解析。
67
+
68
+ Args:
69
+ config_path (str): 配置文件路径或HTTP(S) URL
70
+ proxy (list[str] | None): 代理列表,仅用于HTTP或HTTPS请求
71
+ ssl (bool | str): SSL验证配置,仅用于HTTPS请求
72
+
73
+ Returns:
74
+ dict|list[dict]: 配置字典或配置字典数组
75
+
76
+ Raises:
77
+ Exception: 当配置文件加载失败时抛出异常
78
+ """
79
+ try:
80
+ # 检查是否为远程URL
81
+ if "://" in config_path:
82
+ # 使用HTTP请求获取远程配置
83
+ response = request("GET", config_path, proxies=proxy, verify=ssl, retries=3)
84
+ if (response.status not in (200, None)) or not response.body:
85
+ stderr.write("Failed to load {}: HTTP {} {}\n".format(config_path, response.status, response.reason))
86
+ stderr.write("Response body: %s\n" % response.body)
87
+ raise Exception("HTTP {}: {}".format(response.status, response.reason))
88
+ content = response.body
89
+ else:
90
+ # 本地文件加载
91
+ content = read_file(config_path)
92
+
93
+ # 移除注释后尝试JSON解析
94
+ content_without_comments = remove_comment(content)
95
+ try:
96
+ config = json_decode(content_without_comments)
97
+ except (ValueError, SyntaxError) as json_error:
98
+ # JSON解析失败,尝试AST解析
99
+ try:
100
+ config = literal_eval(content)
101
+ stdout.write("Successfully loaded config file with AST parser: %s\n" % config_path)
102
+ except (ValueError, SyntaxError) as ast_error:
103
+ if config_path.endswith(".json"):
104
+ stderr.write("JSON parsing failed for %s\n" % (config_path))
105
+ raise json_error
106
+
107
+ stderr.write(
108
+ "Both JSON and AST parsing failed for %s\nJSON Error: %s\nAST Error: %s\n"
109
+ % (config_path, json_error, ast_error)
110
+ )
111
+ raise ast_error
112
+ except Exception as e:
113
+ stderr.write("Failed to load config file `%s`: %s\n" % (config_path, e))
114
+ raise
115
+
116
+ # 处理配置格式:v4.1 providers格式或单个对象
117
+ if "providers" in config and isinstance(config["providers"], list):
118
+ return _process_multi_providers(config)
119
+ else:
120
+ return _flatten_single_config(config)
121
+
122
+
123
+ def save_config(config_path, config):
124
+ # type: (str, dict) -> bool
125
+ """
126
+ 保存配置到文件。
127
+
128
+ Args:
129
+ config_path (str): 配置文件路径
130
+ config (dict): 配置字典
131
+
132
+ Returns:
133
+ bool: 保存成功返回True
134
+
135
+ Raises:
136
+ Exception: 保存失败时抛出异常
137
+ """
138
+ # 补全默认配置
139
+ config = {
140
+ "$schema": "https://ddns.newfuture.cc/schema/v4.1.json",
141
+ "dns": config.get("dns", "debug"),
142
+ "id": config.get("id", "YOUR ID or EMAIL for DNS Provider"),
143
+ "token": config.get("token", "YOUR TOKEN or KEY for DNS Provider"),
144
+ "ipv4": config.get("ipv4", ["ddns.newfuture.cc"]),
145
+ "index4": config.get("index4", ["default"]),
146
+ "ipv6": config.get("ipv6", []),
147
+ "index6": config.get("index6", []),
148
+ "ttl": config.get("ttl", 600),
149
+ "line": config.get("line"),
150
+ "proxy": config.get("proxy", []),
151
+ "cache": config.get("cache", True),
152
+ "ssl": config.get("ssl", "auto"),
153
+ "log": {
154
+ "file": config.get("log_file"),
155
+ "level": config.get("log_level", "INFO"),
156
+ "format": config.get("log_format"),
157
+ "datefmt": config.get("log_datefmt"),
158
+ },
159
+ }
160
+ try:
161
+ content = json_encode(config, indent=2, ensure_ascii=False)
162
+ # Python 2 兼容性:检查是否需要解码
163
+ if hasattr(content, "decode"):
164
+ content = content.decode("utf-8") # type: ignore
165
+ write_file(config_path, content)
166
+ return True
167
+ except Exception:
168
+ stderr.write("Cannot open config file to write: `%s`!\n" % config_path)
169
+ raise
@@ -49,14 +49,33 @@ class CloudflareProvider(BaseProvider):
49
49
 
50
50
  def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
51
51
  # type: (str, str, str, str, str | None, dict) -> dict | None
52
- """https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/list/"""
52
+ """
53
+ 查询DNS记录,优先使用extra filter匹配,匹配不到则fallback到不带extra的结果
54
+
55
+ Query DNS records, prioritize extra filters, fallback to query without extra if no match found.
56
+ https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/list/
57
+ """
53
58
  # cloudflare的域名查询需要完整域名
54
59
  name = join_domain(subdomain, main_domain)
55
60
  query = {"name.exact": name} # type: dict[str, str|None]
56
- if extra:
57
- query["proxied"] = extra.get("proxied", None) # 代理状态
61
+
62
+ # 添加extra filter到查询参数,将布尔值转换为小写字符串
63
+ proxied = extra.get("proxied") if extra else None
64
+ if proxied is not None:
65
+ query["proxied"] = str(proxied).lower() # True -> "true", False -> "false"
66
+
67
+ # 先使用extra filter查询
58
68
  data = self._request("GET", "/{}/dns_records".format(zone_id), type=record_type, per_page=10000, **query)
59
69
  record = next((r for r in data if r.get("name") == name and r.get("type") == record_type), None)
70
+
71
+ # 如果使用了extra filter但没找到记录,尝试不带extra filter查询
72
+ if not record and proxied is not None:
73
+ self.logger.debug("No record found with extra filters, retrying without extra filters")
74
+ data = self._request(
75
+ "GET", "/{}/dns_records".format(zone_id), type=record_type, per_page=10000, **{"name.exact": name}
76
+ )
77
+ record = next((r for r in data if r.get("name") == name and r.get("type") == record_type), None)
78
+
60
79
  self.logger.debug("Record queried: %s", record)
61
80
  if record:
62
81
  return record
@@ -81,9 +100,9 @@ class CloudflareProvider(BaseProvider):
81
100
  # type: (str, dict, str, str, int | str | None, str | None, dict) -> bool
82
101
  """https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/"""
83
102
  extra["comment"] = extra.get("comment", self.remark) # 注释
84
- extra["proxied"] = old_record.get("proxied", extra.get("proxied")) # 保持原有的代理状态
85
- extra["tags"] = old_record.get("tags", extra.get("tags")) # 保持原有的标签
86
- extra["settings"] = old_record.get("settings", extra.get("settings")) # 保持原有的设置
103
+ extra["proxied"] = extra.get("proxied", old_record.get("proxied")) # extra优先,保持原有的代理状态作为默认值
104
+ extra["tags"] = extra.get("tags", old_record.get("tags")) # extra优先,保持原有的标签作为默认值
105
+ extra["settings"] = extra.get("settings", old_record.get("settings")) # extra优先,保持原有的设置作为默认值
87
106
  data = self._request(
88
107
  "PUT",
89
108
  "/{}/dns_records/{}".format(zone_id, old_record["id"]),
@@ -0,0 +1,72 @@
1
+ # -*- coding:utf-8 -*-
2
+ """
3
+ Task scheduler management
4
+ Provides factory functions and public API for task scheduling
5
+ @author: NewFuture
6
+ """
7
+
8
+ import os
9
+ import platform
10
+
11
+ from ddns.util.fileio import read_file_safely
12
+
13
+ from ..util.try_run import try_run
14
+
15
+ # Import all scheduler classes
16
+ from ._base import BaseScheduler
17
+ from .cron import CronScheduler
18
+ from .launchd import LaunchdScheduler
19
+ from .schtasks import SchtasksScheduler
20
+ from .systemd import SystemdScheduler
21
+
22
+
23
+ def get_scheduler(scheduler=None):
24
+ # type: (str | None) -> BaseScheduler
25
+ """
26
+ Factory function to get appropriate scheduler based on platform or user preference
27
+
28
+ Args:
29
+ scheduler: Scheduler type. Can be:
30
+ - None or "auto": Auto-detect based on platform
31
+ - "systemd": Use systemd timer (Linux)
32
+ - "cron": Use cron jobs (Unix/Linux)
33
+ - "launchd": Use launchd (macOS)
34
+ - "schtasks": Use Windows Task Scheduler
35
+
36
+ Returns:
37
+ Appropriate scheduler instance
38
+
39
+ Raises:
40
+ ValueError: If invalid scheduler specified
41
+ NotImplementedError: If scheduler not available on current platform
42
+ """
43
+ # Auto-detect if not specified
44
+ if scheduler is None or scheduler == "auto":
45
+ system = platform.system().lower()
46
+ if system == "windows":
47
+ return SchtasksScheduler()
48
+ elif system == "darwin": # macOS
49
+ # Check if launchd directories exist
50
+ launchd_dirs = ["/Library/LaunchDaemons", "/System/Library/LaunchDaemons"]
51
+ if any(os.path.isdir(d) for d in launchd_dirs):
52
+ return LaunchdScheduler()
53
+ elif system == "linux" and (
54
+ (read_file_safely("/proc/1/comm", default="").strip().lower() == "systemd")
55
+ or (try_run(["systemctl", "--version"]) is not None)
56
+ ): # Linux with systemd available
57
+ return SystemdScheduler()
58
+ return CronScheduler() # Other Unix-like systems, use cron
59
+ elif scheduler == "systemd":
60
+ return SystemdScheduler()
61
+ elif scheduler == "cron":
62
+ return CronScheduler()
63
+ elif scheduler == "launchd" or scheduler == "mac":
64
+ return LaunchdScheduler()
65
+ elif scheduler == "schtasks" or scheduler == "windows":
66
+ return SchtasksScheduler()
67
+ else:
68
+ raise ValueError("Invalid scheduler: {}. ".format(scheduler))
69
+
70
+
71
+ # Export public API
72
+ __all__ = ["get_scheduler", "BaseScheduler"]
@@ -0,0 +1,80 @@
1
+ # -*- coding:utf-8 -*-
2
+ """
3
+ Base scheduler class for DDNS task management
4
+ @author: NewFuture
5
+ """
6
+
7
+ import sys
8
+ from datetime import datetime
9
+ from logging import Logger, getLogger # noqa: F401
10
+
11
+ from .. import __version__ as version
12
+
13
+
14
+ class BaseScheduler(object):
15
+ """Base class for all task schedulers"""
16
+
17
+ def __init__(self, logger=None): # type: (Logger | None) -> None
18
+ self.logger = (logger or getLogger()).getChild("task")
19
+
20
+ def _get_ddns_cmd(self): # type: () -> list[str]
21
+ """Get DDNS command for scheduled execution as array"""
22
+ if hasattr(sys.modules["__main__"], "__compiled__"):
23
+ return [sys.argv[0]]
24
+ else:
25
+ return [sys.executable, "-m", "ddns"]
26
+
27
+ def _build_ddns_command(self, ddns_args=None): # type: (dict | None) -> list[str]
28
+ """Build DDNS command with arguments as array"""
29
+ # Get base command as array
30
+ cmd_parts = self._get_ddns_cmd()
31
+
32
+ if not ddns_args:
33
+ return cmd_parts
34
+
35
+ # Filter out debug=False to reduce noise
36
+ args = {k: v for k, v in ddns_args.items() if not (k == "debug" and not v)}
37
+
38
+ for key, value in args.items():
39
+ if isinstance(value, bool):
40
+ cmd_parts.extend(["--{}".format(key), str(value).lower()])
41
+ elif isinstance(value, list):
42
+ for item in value:
43
+ cmd_parts.extend(["--{}".format(key), str(item)])
44
+ else:
45
+ cmd_parts.extend(["--{}".format(key), str(value)])
46
+
47
+ return cmd_parts
48
+
49
+ def _quote_command_array(self, cmd_array): # type: (list[str]) -> str
50
+ """Convert command array to properly quoted command string"""
51
+ return " ".join('"{}"'.format(arg) if " " in arg else arg for arg in cmd_array)
52
+
53
+ def _get_description(self): # type: () -> str
54
+ """Generate standard description/comment for DDNS installation"""
55
+ date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
56
+ return "auto-update v{} installed on {}".format(version, date)
57
+
58
+ def is_installed(self): # type: () -> bool
59
+ """Check if DDNS task is installed"""
60
+ raise NotImplementedError
61
+
62
+ def get_status(self): # type: () -> dict
63
+ """Get detailed status information"""
64
+ raise NotImplementedError
65
+
66
+ def install(self, interval, ddns_args=None): # type: (int, dict | None) -> bool
67
+ """Install DDNS scheduled task"""
68
+ raise NotImplementedError
69
+
70
+ def uninstall(self): # type: () -> bool
71
+ """Uninstall DDNS scheduled task"""
72
+ raise NotImplementedError
73
+
74
+ def enable(self): # type: () -> bool
75
+ """Enable DDNS scheduled task"""
76
+ raise NotImplementedError
77
+
78
+ def disable(self): # type: () -> bool
79
+ """Disable DDNS scheduled task"""
80
+ raise NotImplementedError