ddns 4.1.0b3__py2.py3-none-any.whl → 4.1.1b1__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.

Potentially problematic release.


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

@@ -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
ddns/scheduler/cron.py ADDED
@@ -0,0 +1,111 @@
1
+ # -*- coding:utf-8 -*-
2
+ """
3
+ Cron-based task scheduler for Unix-like systems
4
+ @author: NewFuture
5
+ """
6
+
7
+ import os
8
+ import subprocess
9
+ import tempfile
10
+
11
+ from ..util.fileio import write_file
12
+ from ..util.try_run import try_run
13
+ from ._base import BaseScheduler
14
+
15
+
16
+ class CronScheduler(BaseScheduler):
17
+ """Cron-based task scheduler for Unix-like systems"""
18
+
19
+ SCHEDULER_NAME = "cron"
20
+
21
+ KEY = "# DDNS:"
22
+
23
+ def _update_crontab(self, lines): # type: (list[str]) -> bool
24
+ """Update crontab with new content"""
25
+ try:
26
+ temp_path = tempfile.mktemp(suffix=".cron")
27
+ write_file(temp_path, u"\n".join(lines) + u"\n") # fmt: skip
28
+ subprocess.check_call(["crontab", temp_path])
29
+ os.unlink(temp_path)
30
+ return True
31
+ except Exception as e:
32
+ self.logger.error("Failed to update crontab: %s", e)
33
+ return False
34
+
35
+ def is_installed(self, crontab_content=None): # type: (str | None) -> bool
36
+ result = crontab_content or try_run(["crontab", "-l"], logger=self.logger) or ""
37
+ return self.KEY in result
38
+
39
+ def get_status(self):
40
+ status = {"scheduler": "cron", "installed": False} # type: dict[str, str | bool | int | None]
41
+ # Get crontab content once and reuse it for all checks
42
+ crontab_content = try_run(["crontab", "-l"], logger=self.logger) or ""
43
+ lines = crontab_content.splitlines()
44
+ line = next((i for i in lines if self.KEY in i), "").strip()
45
+
46
+ if line: # Task is installed
47
+ status["installed"] = True
48
+ status["enabled"] = bool(line and not line.startswith("#"))
49
+ else: # Task not installed
50
+ status["enabled"] = False
51
+
52
+ cmd_groups = line.split(self.KEY, 1) if line else ["", ""]
53
+ parts = cmd_groups[0].strip(" #\t").split() if cmd_groups[0] else []
54
+ status["interval"] = int(parts[0][2:]) if len(parts) >= 5 and parts[0].startswith("*/") else None
55
+ status["command"] = " ".join(parts[5:]) if len(parts) >= 6 else None
56
+ status["description"] = cmd_groups[1].strip() if len(cmd_groups) > 1 else None
57
+
58
+ return status
59
+
60
+ def install(self, interval, ddns_args=None):
61
+ ddns_commands = self._build_ddns_command(ddns_args)
62
+ # Convert array to properly quoted command string for cron
63
+ ddns_command = self._quote_command_array(ddns_commands)
64
+ description = self._get_description()
65
+ cron_entry = '*/{} * * * * cd "{}" && {} # DDNS: {}'.format(interval, os.getcwd(), ddns_command, description)
66
+
67
+ crontext = try_run(["crontab", "-l"], logger=self.logger) or ""
68
+ lines = [line for line in crontext.splitlines() if self.KEY not in line]
69
+ lines.append(cron_entry)
70
+
71
+ if self._update_crontab(lines):
72
+ return True
73
+ else:
74
+ self.logger.error("Failed to install DDNS cron job")
75
+ return False
76
+
77
+ def uninstall(self):
78
+ return self._modify_cron_lines("uninstall")
79
+
80
+ def enable(self):
81
+ return self._modify_cron_lines("enable")
82
+
83
+ def disable(self):
84
+ return self._modify_cron_lines("disable")
85
+
86
+ def _modify_cron_lines(self, action): # type: (str) -> bool
87
+ """Helper to enable, disable, or uninstall cron lines"""
88
+ crontext = try_run(["crontab", "-l"], logger=self.logger)
89
+ if not crontext or self.KEY not in crontext:
90
+ self.logger.info("No crontab found")
91
+ return False
92
+
93
+ modified_lines = []
94
+ for line in crontext.rstrip("\n").splitlines():
95
+ if self.KEY not in line:
96
+ modified_lines.append(line)
97
+ elif action == "uninstall":
98
+ continue # Skip DDNS lines (remove them)
99
+ elif action == "enable" and line.strip().startswith("#"):
100
+ uncommented = line.lstrip(" #\t").lstrip() # Enable: uncomment the line
101
+ modified_lines.append(uncommented if uncommented else line)
102
+ elif action == "disable" and not line.strip().startswith("#"):
103
+ modified_lines.append("# " + line) # Disable: comment the line
104
+ else:
105
+ raise ValueError("Invalid action: {}".format(action))
106
+
107
+ if self._update_crontab(modified_lines):
108
+ return True
109
+ else:
110
+ self.logger.error("Failed to %s DDNS cron job", action)
111
+ return False
@@ -0,0 +1,130 @@
1
+ # -*- coding:utf-8 -*-
2
+ """
3
+ macOS launchd-based task scheduler
4
+ @author: NewFuture
5
+ """
6
+
7
+ import os
8
+ import re
9
+
10
+ from ..util.fileio import read_file_safely, write_file
11
+ from ..util.try_run import try_run
12
+ from ._base import BaseScheduler
13
+
14
+
15
+ class LaunchdScheduler(BaseScheduler):
16
+ """macOS launchd-based task scheduler"""
17
+
18
+ LABEL = "cc.newfuture.ddns"
19
+
20
+ def _get_plist_path(self):
21
+ return os.path.expanduser("~/Library/LaunchAgents/{}.plist".format(self.LABEL))
22
+
23
+ def is_installed(self):
24
+ return os.path.exists(self._get_plist_path())
25
+
26
+ def get_status(self):
27
+ # Read plist content once and use it to determine installation status
28
+ content = read_file_safely(self._get_plist_path())
29
+ status = {"scheduler": "launchd", "installed": bool(content)}
30
+ if not content:
31
+ return status
32
+
33
+ # For launchd, check if service is actually loaded/enabled
34
+ result = try_run(["launchctl", "list"], logger=self.logger)
35
+ status["enabled"] = bool(result) and self.LABEL in result
36
+
37
+ # Get interval
38
+ interval_match = re.search(r"<key>StartInterval</key>\s*<integer>(\d+)</integer>", content)
39
+ status["interval"] = int(interval_match.group(1)) // 60 if interval_match else None
40
+
41
+ # Get command
42
+ program_match = re.search(r"<key>Program</key>\s*<string>([^<]+)</string>", content)
43
+ if program_match:
44
+ status["command"] = program_match.group(1)
45
+ else:
46
+ args_section = re.search(r"<key>ProgramArguments</key>\s*<array>(.*?)</array>", content, re.DOTALL)
47
+ if args_section:
48
+ strings = re.findall(r"<string>([^<]+)</string>", args_section.group(1))
49
+ if strings:
50
+ status["command"] = " ".join(strings)
51
+
52
+ # Get comments
53
+ desc_match = re.search(r"<key>Description</key>\s*<string>([^<]+)</string>", content)
54
+ status["description"] = desc_match.group(1) if desc_match else None
55
+
56
+ return status
57
+
58
+ def install(self, interval, ddns_args=None):
59
+ plist_path = self._get_plist_path()
60
+ program_args = self._build_ddns_command(ddns_args)
61
+
62
+ # Create comment with version and install date (consistent with Windows)
63
+ comment = self._get_description()
64
+
65
+ # Generate plist XML using template string for proper macOS plist format
66
+ program_args_xml = "".join(" <string>{}</string>\n".format(arg) for arg in program_args)
67
+
68
+ plist_content = """<?xml version="1.0" encoding="UTF-8"?>
69
+ <plist version="1.0">
70
+ <dict>
71
+ <key>Label</key>
72
+ <string>{label}</string>
73
+ <key>Description</key>
74
+ <string>{description}</string>
75
+ <key>ProgramArguments</key>
76
+ <array>{program}</array>
77
+ <key>StartInterval</key>
78
+ <integer>{interval}</integer>
79
+ <key>RunAtLoad</key>
80
+ <true/>
81
+ <key>WorkingDirectory</key>
82
+ <string>{dir}</string>
83
+ </dict>
84
+ </plist>""".format(
85
+ label=self.LABEL, description=comment, program=program_args_xml, interval=interval * 60, dir=os.getcwd()
86
+ )
87
+
88
+ write_file(plist_path, plist_content)
89
+ result = try_run(["launchctl", "load", plist_path], logger=self.logger)
90
+ if result is not None:
91
+ self.logger.info("DDNS launchd service installed successfully")
92
+ return True
93
+ else:
94
+ self.logger.error("Failed to load launchd service")
95
+ return False
96
+
97
+ def uninstall(self):
98
+ plist_path = self._get_plist_path()
99
+ try_run(["launchctl", "unload", plist_path], logger=self.logger) # Ignore errors
100
+ try:
101
+ os.remove(plist_path)
102
+ except OSError:
103
+ pass
104
+
105
+ self.logger.info("DDNS launchd service uninstalled successfully")
106
+ return True
107
+
108
+ def enable(self):
109
+ plist_path = self._get_plist_path()
110
+ if not os.path.exists(plist_path):
111
+ self.logger.error("DDNS launchd service not found")
112
+ return False
113
+
114
+ result = try_run(["launchctl", "load", plist_path], logger=self.logger)
115
+ if result is not None:
116
+ self.logger.info("DDNS launchd service enabled successfully")
117
+ return True
118
+ else:
119
+ self.logger.error("Failed to enable launchd service")
120
+ return False
121
+
122
+ def disable(self):
123
+ plist_path = self._get_plist_path()
124
+ result = try_run(["launchctl", "unload", plist_path], logger=self.logger)
125
+ if result is not None:
126
+ self.logger.info("DDNS launchd service disabled successfully")
127
+ return True
128
+ else:
129
+ self.logger.error("Failed to disable launchd service")
130
+ return False
@@ -0,0 +1,120 @@
1
+ # -*- coding:utf-8 -*-
2
+ """
3
+ schtasks-based task scheduler
4
+ @author: NewFuture
5
+ """
6
+
7
+ import os
8
+ import re
9
+ import tempfile
10
+
11
+ from ..util.try_run import try_run
12
+ from ._base import BaseScheduler
13
+
14
+
15
+ class SchtasksScheduler(BaseScheduler):
16
+ """schtasks-based task scheduler"""
17
+
18
+ NAME = "DDNS"
19
+
20
+ def _schtasks(self, *args):
21
+ """Helper to run schtasks commands with consistent error handling"""
22
+ result = try_run(["schtasks"] + list(args), logger=self.logger)
23
+ return result is not None
24
+
25
+ def _extract_xml(self, xml_text, tag_name):
26
+ """Extract XML tag content using regex for better performance and flexibility"""
27
+ pattern = r"<{0}[^>]*>(.*?)</{0}>".format(re.escape(tag_name))
28
+ match = re.search(pattern, xml_text, re.DOTALL)
29
+ return match.group(1).strip() if match else None
30
+
31
+ def is_installed(self):
32
+ result = try_run(["schtasks", "/query", "/tn", self.NAME], logger=self.logger) or ""
33
+ return self.NAME in result
34
+
35
+ def get_status(self):
36
+ # Use XML format for language-independent parsing
37
+ task_xml = try_run(["schtasks", "/query", "/tn", self.NAME, "/xml"], logger=self.logger)
38
+ status = {"scheduler": "schtasks", "installed": bool(task_xml)}
39
+
40
+ if not task_xml:
41
+ return status # Task not installed, return minimal status
42
+
43
+ status["enabled"] = self._extract_xml(task_xml, "Enabled") != "false"
44
+ command = self._extract_xml(task_xml, "Command")
45
+ arguments = self._extract_xml(task_xml, "Arguments")
46
+ status["command"] = "{} {}".format(command, arguments) if command and arguments else command
47
+
48
+ # Parse interval: PT10M -> 10, fallback to original string
49
+ interval_str = self._extract_xml(task_xml, "Interval")
50
+ interval_match = re.search(r"PT(\d+)M", interval_str) if interval_str else None
51
+ status["interval"] = int(interval_match.group(1)) if interval_match else interval_str
52
+
53
+ # Show description if exists, otherwise show installation date
54
+ description = self._extract_xml(task_xml, "Description") or self._extract_xml(task_xml, "Date")
55
+ if description:
56
+ status["description"] = description
57
+ return status
58
+
59
+ def install(self, interval, ddns_args=None):
60
+ # Build command line as array: prefer pythonw for script mode, or compiled exe directly
61
+ cmd_array = self._build_ddns_command(ddns_args)
62
+ workdir = os.getcwd()
63
+
64
+ # Split array into executable and arguments for schtasks XML format
65
+ # For Windows scheduler, prefer pythonw.exe to avoid console window
66
+ executable = cmd_array[0].replace("python.exe", "pythonw.exe")
67
+ arguments = self._quote_command_array(cmd_array[1:]) if len(cmd_array) > 1 else ""
68
+
69
+ # Create XML task definition with working directory support
70
+ description = self._get_description()
71
+
72
+ # Use template string to generate Windows Task Scheduler XML
73
+ xml_content = """<?xml version="1.0" encoding="UTF-16"?>
74
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
75
+ <RegistrationInfo>
76
+ <Description>{description}</Description>
77
+ </RegistrationInfo>
78
+ <Triggers>
79
+ <TimeTrigger>
80
+ <StartBoundary>1900-01-01T00:00:00</StartBoundary>
81
+ <Repetition>
82
+ <Interval>PT{interval}M</Interval>
83
+ </Repetition>
84
+ <Enabled>true</Enabled>
85
+ </TimeTrigger>
86
+ </Triggers>
87
+ <Actions>
88
+ <Exec>
89
+ <Command>{exe}</Command>
90
+ <Arguments>{arguments}</Arguments>
91
+ <WorkingDirectory>{dir}</WorkingDirectory>
92
+ </Exec>
93
+ </Actions>
94
+ <Settings>
95
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
96
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
97
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
98
+ </Settings>
99
+ </Task>""".format(description=description, interval=interval, exe=executable, arguments=arguments, dir=workdir)
100
+
101
+ # Write XML to temp file and use it to create task
102
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".xml", delete=False) as f:
103
+ f.write(xml_content)
104
+ xml_file = f.name
105
+
106
+ try:
107
+ success = self._schtasks("/Create", "/XML", xml_file, "/TN", self.NAME, "/F")
108
+ return success
109
+ finally:
110
+ os.unlink(xml_file)
111
+
112
+ def uninstall(self):
113
+ success = self._schtasks("/Delete", "/TN", self.NAME, "/F")
114
+ return success
115
+
116
+ def enable(self):
117
+ return self._schtasks("/Change", "/TN", self.NAME, "/Enable")
118
+
119
+ def disable(self):
120
+ return self._schtasks("/Change", "/TN", self.NAME, "/Disable")
@@ -0,0 +1,139 @@
1
+ # -*- coding:utf-8 -*-
2
+ """
3
+ Systemd timer-based task scheduler for Linux
4
+ @author: NewFuture
5
+ """
6
+
7
+ import os
8
+ import re
9
+
10
+ from ..util.fileio import read_file_safely, write_file
11
+ from ..util.try_run import try_run
12
+ from ._base import BaseScheduler
13
+
14
+ try: # python 3
15
+ PermissionError # type: ignore
16
+ except NameError: # python 2 doesn't have PermissionError, use OSError instead
17
+ PermissionError = IOError
18
+
19
+
20
+ class SystemdScheduler(BaseScheduler):
21
+ """Systemd timer-based task scheduler for Linux"""
22
+
23
+ SERVICE_NAME = "ddns.service"
24
+ TIMER_NAME = "ddns.timer"
25
+ SERVICE_PATH = "/etc/systemd/system/ddns.service"
26
+ TIMER_PATH = "/etc/systemd/system/ddns.timer"
27
+
28
+ def _systemctl(self, *args):
29
+ """Run systemctl command and return success status"""
30
+ result = try_run(["systemctl"] + list(args), logger=self.logger)
31
+ return result is not None
32
+
33
+ def is_installed(self):
34
+ """Check if systemd timer files exist"""
35
+ return os.path.exists(self.SERVICE_PATH) and os.path.exists(self.TIMER_PATH)
36
+
37
+ def get_status(self):
38
+ """Get comprehensive status information"""
39
+ installed = self.is_installed()
40
+ status = {"scheduler": "systemd", "installed": installed}
41
+ if not installed:
42
+ return status
43
+
44
+ # Check if timer is enabled
45
+ result = try_run(["systemctl", "is-enabled", self.TIMER_NAME], logger=self.logger)
46
+ status["enabled"] = bool(result and result.strip() == "enabled")
47
+
48
+ # Extract interval from timer file
49
+ timer_content = read_file_safely(self.TIMER_PATH) or ""
50
+ match = re.search(r"OnUnitActiveSec=(\d+)m", timer_content)
51
+ status["interval"] = int(match.group(1)) if match else None
52
+
53
+ # Extract command and description from service file
54
+ service_content = read_file_safely(self.SERVICE_PATH) or ""
55
+ match = re.search(r"ExecStart=(.+)", service_content)
56
+ status["command"] = match.group(1).strip() if match else None
57
+ desc_match = re.search(r"Description=(.+)", service_content)
58
+ status["description"] = desc_match.group(1).strip() if desc_match else None
59
+
60
+ return status
61
+
62
+ def install(self, interval, ddns_args=None):
63
+ """Install systemd timer with specified interval"""
64
+ ddns_commands = self._build_ddns_command(ddns_args)
65
+ # Convert array to properly quoted command string for ExecStart
66
+ ddns_command = self._quote_command_array(ddns_commands)
67
+ work_dir = os.getcwd()
68
+ description = self._get_description()
69
+
70
+ # Create service file content
71
+ service_content = u"""[Unit]
72
+ Description={}
73
+ After=network.target
74
+
75
+ [Service]
76
+ Type=oneshot
77
+ WorkingDirectory={}
78
+ ExecStart={}
79
+ """.format(description, work_dir, ddns_command) # fmt: skip
80
+
81
+ # Create timer file content
82
+ timer_content = u"""[Unit]
83
+ Description=DDNS automatic IP update timer
84
+ Requires={}
85
+
86
+ [Timer]
87
+ OnUnitActiveSec={}m
88
+ Unit={}
89
+
90
+ [Install]
91
+ WantedBy=multi-user.target
92
+ """.format(self.SERVICE_NAME, interval, self.SERVICE_NAME) # fmt: skip
93
+
94
+ try:
95
+ # Write service and timer files
96
+ write_file(self.SERVICE_PATH, service_content)
97
+ write_file(self.TIMER_PATH, timer_content)
98
+ except PermissionError as e:
99
+ self.logger.debug("Permission denied when writing systemd files: %s", e)
100
+ print("Permission denied. Please run as root or use sudo.")
101
+ print("or use cron scheduler (with --scheduler=cron) instead.")
102
+ return False
103
+ except Exception as e:
104
+ self.logger.error("Failed to write systemd files: %s", e)
105
+ return False
106
+
107
+ if self._systemctl("daemon-reload") and self._systemctl("enable", self.TIMER_NAME):
108
+ self._systemctl("start", self.TIMER_NAME)
109
+ return True
110
+ else:
111
+ self.logger.error("Failed to enable/start systemd timer")
112
+ return False
113
+
114
+ def uninstall(self):
115
+ """Uninstall systemd timer and service"""
116
+ self.disable() # Stop and disable timer
117
+ # Remove systemd files
118
+ try:
119
+ os.remove(self.SERVICE_PATH)
120
+ os.remove(self.TIMER_PATH)
121
+ self._systemctl("daemon-reload") # Reload systemd configuration
122
+ return True
123
+
124
+ except PermissionError as e:
125
+ self.logger.debug("Permission denied when removing systemd files: %s", e)
126
+ print("Permission denied. Please run as root or use sudo.")
127
+ return False
128
+ except Exception as e:
129
+ self.logger.error("Failed to remove systemd files: %s", e)
130
+ return False
131
+
132
+ def enable(self):
133
+ """Enable and start systemd timer"""
134
+ return self._systemctl("enable", self.TIMER_NAME) and self._systemctl("start", self.TIMER_NAME)
135
+
136
+ def disable(self):
137
+ """Disable and stop systemd timer"""
138
+ self._systemctl("stop", self.TIMER_NAME)
139
+ return self._systemctl("disable", self.TIMER_NAME)
ddns/util/fileio.py CHANGED
@@ -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
ddns/util/try_run.py ADDED
@@ -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.1b1
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