Jelajahi Sumber

refact(py): Refactor structure (#481)

* 目录结构重构
* update config
* rename dns to provider
* patch version info
* remove version replace
Co-authored-by: Copilot <[email protected]>
* add ENV GITHUB_REF_NAME to docker build
---------
Co-authored-by: Copilot <[email protected]>
New Future 6 bulan lalu
induk
melakukan
baf1fdc30c

+ 84 - 71
.github/patch.py

@@ -1,18 +1,19 @@
 #!/usr/bin/env python3
-"""
-自动将所有 try-except python2/3 兼容导入替换为 python3 only 导入,并显示处理日志
-"""
+
 import os
 import re
+import time
+
+ROOT = "."
+init_py_path = os.path.join(ROOT, "ddns", "__init__.py")
 
-ROOT = '.'
 # 匹配 try-except 块,去除导入前缩进,保证import顶格,删除的行用空行代替
 PATTERN = re.compile(
-    r'^[ \t]*try:[^\n]*python 3[^\n]*\n'      # try: # python 3
-    r'((?:[ \t]+[^\n]*\n)+?)'                 # python3 导入内容
-    r'^[ \t]*except ImportError:[^\n]*\n'     # except ImportError: # python 2
-    r'((?:[ \t]+from[^\n]*\n|[ \t]+import[^\n]*\n)*)',  # except块内容
-    re.MULTILINE
+    r"^[ \t]*try:[^\n]*python 3[^\n]*\n"  # try: # python 3
+    r"((?:[ \t]+[^\n]*\n)+?)"  # python3 导入内容
+    r"^[ \t]*except ImportError:[^\n]*\n"  # except ImportError: # python 2
+    r"((?:[ \t]+from[^\n]*\n|[ \t]+import[^\n]*\n)*)",  # except块内容
+    re.MULTILINE,
 )
 
 
@@ -20,49 +21,36 @@ def dedent_imports_with_blank(import_block, try_block, except_block):
     """
     保留python3导入并去除缩进,try/except及except内容用空行代替
     """
-    try_lines = try_block.count('\n')
-    except_lines = except_block.count('\n')
-    imports = ''.join(line.lstrip()
-                      for line in import_block.splitlines(keepends=True))
-    return ('\n' * try_lines) + imports + ('\n' * except_lines)
+    try_lines = try_block.count("\n")
+    except_lines = except_block.count("\n")
+    imports = "".join(line.lstrip() for line in import_block.splitlines(keepends=True))
+    return ("\n" * try_lines) + imports + ("\n" * except_lines)
 
 
 def extract_pure_version(version_str):
     """
     提取前4组数字并用点拼接,如 v1.2.3.beta4.5 -> 1.2.3.4
     """
-    import re
-    nums = re.findall(r'\d+', version_str)
-    return '.'.join(nums[:4]) if nums else "0.0.0"
+    nums = re.findall(r"\d+", version_str)
+    return ".".join(nums[:4]) if nums else "0.0.0"
 
 
-def update_nuitka_version(pyfile):
+def update_nuitka_version(pyfile, version=None):
     """
     读取 __version__ 并替换 nuitka-project 版本号
     """
-    with open(pyfile, 'r', encoding='utf-8') as f:
-        content = f.read()
-
-    # 提取 __version__ 变量
-    version_match = re.search(
-        r'__version__\s*=\s*[\'"]([^\'"]+)[\'"]', content)
-    if not version_match:
-        print(f'No __version__ found in {pyfile}')
-        return False
-
-    version_str = version_match.group(1)
-    pure_version = extract_pure_version(version_str)
+    pure_version = extract_pure_version(version)
 
+    with open(pyfile, "r", encoding="utf-8") as f:
+        content = f.read()
     # 替换 nuitka-project 行
     new_content, n = re.subn(
-        r'(# nuitka-project: --product-version=)[^\n]*',
-        r'\g<1>' + pure_version,
-        content
+        r"(# nuitka-project: --product-version=)[^\n]*", r"\g<1>" + pure_version, content
     )
     if n > 0:
-        with open(pyfile, 'w', encoding='utf-8') as f:
+        with open(pyfile, "w", encoding="utf-8") as f:
             f.write(new_content)
-        print(f'update nuitka-project version: {pure_version} in {pyfile}')
+        print(f"update nuitka-project version: {pure_version} in {pyfile}")
         return True
     return False
 
@@ -71,26 +59,18 @@ def add_nuitka_file_description(pyfile):
     """
     添加 --file-description 配置,使用 __description__ 变量的值
     """
-    with open(pyfile, 'r', encoding='utf-8') as f:
+    with open(init_py_path, "r", encoding="utf-8") as f:
         content = f.read()
 
     # 提取 __description__ 变量的值
-    desc_match = re.search(
-        r'__description__\s*=\s*[\'"]([^\'"]+)[\'"]', content)
+    desc_match = re.search(r'__description__\s*=\s*[\'"]([^\'"]+)[\'"]', content)
     if not desc_match:
-        print(f'No __description__ found in {pyfile}')
+        print(f"No __description__ found in {init_py_path}")
         return False
-
     description = desc_match.group(1)
-    if not content.endswith('\n'):
-        content += '\n'
-    description_line = f'# nuitka-project: --file-description="{description}"\n'
-    if description_line not in content:
-        content += description_line
-
-    with open(pyfile, 'w', encoding='utf-8') as f:
-        f.write(content)
-    print(f'Added file-description to {pyfile}')
+    description_line = f'\n# nuitka-project: --file-description="{description}"\n'
+    with open(pyfile, "a", encoding="utf-8") as f:
+        f.write(description_line)
     return True
 
 
@@ -98,26 +78,26 @@ def add_nuitka_include_modules(pyfile):
     """
     读取 dns 目录下的所有 Python 模块,并添加到 run.py 末尾
     """
-    dns_dir = os.path.join(ROOT, 'dns')
+    dns_dir = os.path.join(ROOT, "ddns/provider")
     if not os.path.exists(dns_dir):
-        print(f'DNS directory not found: {dns_dir}')
+        print(f"DNS directory not found: {dns_dir}")
         return False
 
     # 获取所有 Python 模块文件
     modules = []
     for filename in os.listdir(dns_dir):
-        if filename.endswith('.py') and filename != '__init__.py':
+        if filename.endswith(".py") and filename != "__init__.py":
             module_name = filename[:-3]  # 去掉 .py 扩展名
-            modules.append(f'dns.{module_name}')
+            modules.append(f"ddns.provider.{module_name}")
 
     if not modules:
-        print('No DNS modules found')
+        print("No DNS modules found")
         return False
 
     # 直接在文件末尾追加配置行
-    with open(pyfile, 'a', encoding='utf-8') as f:
+    with open(pyfile, "a", encoding="utf-8") as f:
         for module in sorted(modules):
-            f.write(f'# nuitka-project: --include-module={module}\n')
+            f.write(f"# nuitka-project: --include-module={module}\n")
 
     print(f'Added {len(modules)} DNS modules to {pyfile}: {", ".join(modules)}')
     return True
@@ -125,9 +105,10 @@ def add_nuitka_include_modules(pyfile):
 
 def remove_python2_compatibility(pyfile):
     """
+    自动将所有 try-except python2/3 兼容导入替换为 python3 only 导入,并显示处理日志
     删除指定文件中的 python2 兼容代码,逐行处理
     """
-    with open(pyfile, 'r', encoding='utf-8') as f:
+    with open(pyfile, "r", encoding="utf-8") as f:
         lines = f.readlines()
 
     new_lines = []
@@ -136,28 +117,27 @@ def remove_python2_compatibility(pyfile):
     while i < len(lines):
         line = lines[i]
         # 匹配 "try: # python3" 或 "try: # python 3"
-        if re.match(r'^[ \t]*try:[^\n]*python ?3', line):
+        if re.match(r"^[ \t]*try:[^\n]*python ?3", line):
             try_block = []
             except_block = []
             i += 1
             # 收集try块内容
-            while i < len(lines) and lines[i].startswith((' ', '\t')):
+            while i < len(lines) and lines[i].startswith((" ", "\t")):
                 try_block.append(lines[i].lstrip())
                 i += 1
             # 跳过空行
-            while i < len(lines) and lines[i].strip() == '':
+            while i < len(lines) and lines[i].strip() == "":
                 i += 1
             # 检查是否存在except块 (不检查具体错误类型,但必须包含python2或python 2)
-            if i < len(lines) and re.match(r'^[ \t]*except[^\n]*python ?2', lines[i]):
+            if i < len(lines) and re.match(r"^[ \t]*except[^\n]*python ?2", lines[i]):
                 i += 1
                 # 收集except块内容
                 except_block = []
-                while i < len(lines) and lines[i].startswith((' ', '\t')):
+                while i < len(lines) and lines[i].startswith((" ", "\t")):
                     except_block.append(lines[i])
                     i += 1
                 # 添加try块内容,except块用空行替代
-                new_lines.extend(['\n'] + try_block + ['\n']
-                                 * (len(except_block) + 1))
+                new_lines.extend(["\n"] + try_block + ["\n"] * (len(except_block) + 1))
                 changed = True
             else:
                 # 没有except块,原样保留
@@ -168,9 +148,21 @@ def remove_python2_compatibility(pyfile):
             i += 1
 
     if changed:
-        with open(pyfile, 'w', encoding='utf-8') as f:
+        with open(pyfile, "w", encoding="utf-8") as f:
             f.writelines(new_lines)
-        print(f'Removed python2 compatibility from {pyfile}')
+        print(f"Removed python2 compatibility from {pyfile}")
+
+
+def extract_version_from_env():
+    """
+    从环境变量中提取版本号
+    """
+    ref = os.environ.get("GITHUB_REF_NAME")
+    if not ref:
+        return time.strftime("0.0.%m%d.%H%M")  # 默认版本号
+    if ref and ref.startswith("v"):
+        return ref[1:]  # 去掉前缀 'v'
+    return ref  # 返回原始版本号
 
 
 def main():
@@ -178,20 +170,41 @@ def main():
     遍历所有py文件并替换兼容导入,同时更新nuitka版本号
     """
     run_py_path = os.path.join(ROOT, "run.py")
-    update_nuitka_version(run_py_path)
+    version = extract_version_from_env()
+    update_nuitka_version(run_py_path, version)
     add_nuitka_file_description(run_py_path)
     add_nuitka_include_modules(run_py_path)
 
+    # 修改__init__.py 中的 __version__
+
+    replace_version_in_init(version, init_py_path)
+
     changed_files = 0
     for dirpath, _, filenames in os.walk(ROOT):
         for fname in filenames:
-            if fname.endswith('.py'):
+            if fname.endswith(".py"):
                 fpath = os.path.join(dirpath, fname)
                 remove_python2_compatibility(fpath)
                 changed_files += 1
-    print('done')
-    print(f'Total processed files: {changed_files}')
+    print("done")
+    print(f"Total processed files: {changed_files}")
+
+
+def replace_version_in_init(version, init_py_path):
+    """
+    替换 ddns/__init__.py 中的 __version__ 变量
+    """
+    version_str = f'v{version}@{time.strftime("%Y-%m-%dT%H:%M:%S")}'
+    with open(init_py_path, "r", encoding="utf-8") as f:
+        content = f.read()
+    new_content = re.sub(
+        r'__version__\s*=\s*[\'"]([^\'"]+)[\'"]', f'__version__ = "{version_str}"', content
+    )
+    if new_content != content:
+        with open(init_py_path, "w", encoding="utf-8") as f:
+            f.write(new_content)
+        print(f"Updated __version__ in {init_py_path} to {version_str}")
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     main()

+ 25 - 12
.github/workflows/build.yml

@@ -58,6 +58,23 @@ jobs:
         run: ${{env.PY}} run.py || test -f config.json
       - name: test version
         run: ${{env.PY}} run.py --version
+      - name: test run module
+        run:  ${{env.PY}} -m "ddns" -h
+
+      - name: test patch
+        if:  ${{ matrix.version != '2.7' }}
+        run:  python3 .github/patch.py
+      - name: test help
+        if:  ${{ matrix.version != '2.7' }}
+        run:  python3 run.py -h
+      - name: test run
+        if:  ${{ matrix.version != '2.7' }}
+        run:  python3 run.py || test -f config.json
+      - name: test version
+        run: ${{env.PY}} run.py --version
+      - name: test run module
+        if:  ${{ matrix.version != '2.7' }}
+        run:  ${{env.PY}} -m "ddns" -h
 
   pypi:
     runs-on: ubuntu-latest
@@ -70,9 +87,8 @@ jobs:
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
-          pip install build
-      - run: sed -i -e 's#"doc/img/ddns.svg"#"https://ddns.newfuture.cc/doc/img/ddns.svg"#' README.md
-      - run: sed -i -e "s#\${BUILD_VERSION}#${{ github.ref_name }}#" -e "s/\${BUILD_DATE}/$(date --iso-8601=seconds)/" run.py
+          pip install build setuptools-scm
+      - run: sed -i -E 's#([("])/doc/#\1https://ddns.newfuture.cc/doc/#g' README.md
       - name: Build package
         run: python -m build --sdist --wheel --outdir dist/
 
@@ -116,11 +132,6 @@ jobs:
       - name: remove python2 code
         run:  python3 .github/patch.py
 
-      # Prepare build version and cert
-      - name: Replace build version
-        run: sed -i.tmp -e "s#\${BUILD_VERSION}#${{ github.ref_name }}#" -e "s/\${BUILD_DATE}/$(date --iso-8601=seconds)/" run.py && rm run.py.tmp
-        shell: bash
-        
       - name: Set up on Linux
         if: runner.os == 'Linux'
         run: sudo apt-get install -y --no-install-recommends patchelf
@@ -176,7 +187,6 @@ jobs:
     timeout-minutes: 8
     steps:
       - uses: actions/checkout@v4
-      - run: sed -i -e "s#\${BUILD_VERSION}#${{ github.ref_name }}#" -e "s/\${BUILD_DATE}/$(date --iso-8601=seconds)/" run.py
 
       - uses: docker/setup-buildx-action@v3
       - uses: docker/build-push-action@v6
@@ -188,7 +198,9 @@ jobs:
           tags: ddnsbin
           target: export
           outputs: type=local,dest=./output
-          build-args: BUILDER=ghcr.io/newfuture/nuitka-builder:${{matrix.libc}}-master
+          build-args: |
+            BUILDER=ghcr.io/newfuture/nuitka-builder:${{matrix.libc}}-master
+            GITHUB_REF_NAME=${{ github.ref_name }}
       # 测试构建的二进制文件
       - name: Test built binaries
         run: |
@@ -238,7 +250,6 @@ jobs:
         }}
     steps:
       - uses: actions/checkout@v4
-      - run: sed -i -e "s#\${BUILD_VERSION}#${{ github.ref_name }}#" -e "s/\${BUILD_DATE}/$(date --iso-8601=seconds)/" run.py
       - uses: docker/setup-qemu-action@v3 # 仅仅在需要时启用 QEMU 支持
         if: matrix.host == 'qemu'
         with:
@@ -252,7 +263,9 @@ jobs:
           push: false
           tags: ddns:test
           outputs: type=oci,dest=./multi-platform-image.tar
-          build-args: BUILDER=ghcr.io/newfuture/nuitka-builder:master
+          build-args: |
+            BUILDER=ghcr.io/newfuture/nuitka-builder:master
+            GITHUB_REF_NAME=${{ github.ref_name }}
 
       # 准备测试环境
       - name: Prepare test environment

+ 8 - 10
.github/workflows/publish.yml

@@ -23,7 +23,6 @@ jobs:
       platforms: linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/riscv64,linux/s390x
     steps:
       - uses: actions/checkout@v4
-      - run: sed -i -e "s#\${BUILD_VERSION}#${{ github.ref_name }}#" -e "s/\${BUILD_DATE}/$(date --iso-8601=seconds)/" run.py
       - uses: docker/setup-qemu-action@v3
         with:
           platforms: ${{ env.platforms }}
@@ -60,7 +59,9 @@ jobs:
           tags: ${{ steps.meta.outputs.tags }}
           labels: ${{ steps.meta.outputs.labels }}
           annotations: ${{ steps.meta.outputs.annotations }}
-          build-args: BUILDER=ghcr.io/newfuture/nuitka-builder:master   
+          build-args: |
+            BUILDER=ghcr.io/newfuture/nuitka-builder:master   
+            GITHUB_REF_NAME=${{ github.ref_name }}
 
   publish-pypi:
     runs-on: ubuntu-latest
@@ -73,14 +74,14 @@ jobs:
       id-token: write
     steps:
       - uses: actions/checkout@v4
-      - run: sed -i -e "s#\${BUILD_VERSION}#${{ github.ref_name }}#" -e "s/\${BUILD_DATE}/$(date --iso-8601=seconds)/" run.py
       - uses: actions/setup-python@v5
         with:
           python-version: "3.x"
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
-          pip install build
+          pip install build setuptools-scm
+      - run: sed -i -E 's#([("])/doc/#\1https://ddns.newfuture.cc/doc/#g' README.md
       - name: Build package
         run: python -m build --sdist --wheel --outdir dist/
       - uses: pypa/gh-action-pypi-publish@release/v1
@@ -117,10 +118,6 @@ jobs:
           python-version: "3.12"
           architecture: ${{ matrix.arch }}
 
-      - name: Replace build version
-        run: sed -i.tmp -e "s#\${BUILD_VERSION}#${{ github.ref_name }}#" -e "s/\${BUILD_DATE}/$(date --iso-8601=seconds)/" run.py && rm run.py.tmp
-        shell: bash
-
       - name: remove python2 code
         run:  python3 .github/patch.py
 
@@ -176,7 +173,6 @@ jobs:
     timeout-minutes: 8
     steps:
       - uses: actions/checkout@v4
-      - run: sed -i -e "s#\${BUILD_VERSION}#${{ github.ref_name }}#" -e "s/\${BUILD_DATE}/$(date --iso-8601=seconds)/" run.py
 
       - uses: docker/setup-buildx-action@v3
       - uses: docker/build-push-action@v6
@@ -188,7 +184,9 @@ jobs:
           tags: ddnsbin
           target: export
           outputs: type=local,dest=./output
-          build-args: BUILDER=ghcr.io/newfuture/nuitka-builder:${{matrix.libc}}-master
+          build-args: |
+            BUILDER=ghcr.io/newfuture/nuitka-builder:${{matrix.libc}}-master
+            GITHUB_REF_NAME=${{ github.ref_name }}
       # 测试构建的二进制文件
       - name: Test built binaries
         run: |

+ 6 - 4
README.md

@@ -1,4 +1,4 @@
-# [<img src="doc/img/ddns.svg" width="32px" height="32px"/>](https://ddns.newfuture.cc) [DDNS](https://github.com/NewFuture/DDNS)
+# [<img src="/doc/img/ddns.svg" width="32px" height="32px"/>](https://ddns.newfuture.cc) [DDNS](https://github.com/NewFuture/DDNS)
 
 > 自动更新 DNS 解析 到本机 IP 地址,支持 IPv4 和 IPv6,本地(内网)IP 和公网 IP。
 > 代理模式,支持自动创建 DNS 记录。
@@ -19,9 +19,9 @@
   - [二进制文件](https://github.com/NewFuture/DDNS/releases/latest) ![cross platform](https://img.shields.io/badge/system-windows_%7C%20linux_%7C%20mac-success.svg?style=social)
   
 - 配置方式:
-  - [命令行参数](doc/cli.md)
-  - [JSON 配置文件](doc/json.md)
-  - [环境变量配置](doc/env.md)
+  - [命令行参数](/doc/cli.md)
+  - [JSON 配置文件](/doc/json.md)
+  - [环境变量配置](/doc/env.md)
 
 - 域名支持:
   - 多个域名支持
@@ -97,6 +97,8 @@
       newfuture/ddns
     ```
 
+  更多详细说明和高级用法请查看 [Docker 使用文档](doc/docker.md)。
+
 ### ② 快速配置
 
 1. 申请 api `token`,填写到对应的 `id` 和 `token` 字段:

+ 18 - 0
ddns/__init__.py

@@ -0,0 +1,18 @@
+# -*- coding:utf-8 -*-
+"""
+ddns Package
+"""
+
+
+# __version__ = "${BUILD_VERSION}@${BUILD_DATE}"  # CI 时会被Tag替换
+__version__ = "0.0.0"
+__description__ = "automatically update DNS records to my IP [域名自动指向本机IP]"
+
+__doc__ = """
+ddns[%s]
+(i) homepage or docs [文档主页]: https://ddns.newfuture.cc/
+(?) issues or bugs [问题和帮助]: https://github.com/NewFuture/DDNS/issues
+Copyright (c) New Future (MIT License)
+""" % (
+    __version__
+)

+ 180 - 0
ddns/__main__.py

@@ -0,0 +1,180 @@
+# -*- coding:utf-8 -*-
+"""
+DDNS
+@author: New Future
+@modified: rufengsuixing
+"""
+
+from os import path, environ, name as os_name
+from io import TextIOWrapper
+from subprocess import check_output
+from tempfile import gettempdir
+from logging import basicConfig, info, warning, error, debug, DEBUG, NOTSET
+
+import sys
+
+from .__init__ import __version__ as version, __description__, __doc__
+from .util import ip
+from .util.cache import Cache
+from .util.config import init_config, get_config
+
+environ["DDNS_VERSION"] = "${BUILD_VERSION}"
+
+
+def is_false(value):
+    """
+    判断值是否为 False
+    字符串 'false', 或者 False, 或者 'none';
+    0 不是 False
+    """
+    if isinstance(value, str):
+        return value.strip().lower() in ['false', 'none']
+    return value is False
+
+
+def get_ip(ip_type, index="default"):
+    """
+    get IP address
+    """
+    # CN: 捕获异常
+    # EN: Catch exceptions
+    value = None
+    try:
+        debug("get_ip(%s, %s)", ip_type, index)
+        if is_false(index):  # disabled
+            return False
+        elif isinstance(index, list):  # 如果获取到的规则是列表,则依次判断列表中每一个规则,直到获取到IP
+            for i in index:
+                value = get_ip(ip_type, i)
+                if value:
+                    break
+        elif str(index).isdigit():  # 数字 local eth
+            value = getattr(ip, "local_v" + ip_type)(index)
+        elif index.startswith('cmd:'):  # cmd
+            value = str(check_output(index[4:]).strip().decode('utf-8'))
+        elif index.startswith('shell:'):  # shell
+            value = str(check_output(
+                index[6:], shell=True).strip().decode('utf-8'))
+        elif index.startswith('url:'):  # 自定义 url
+            value = getattr(ip, "public_v" + ip_type)(index[4:])
+        elif index.startswith('regex:'):  # 正则 regex
+            value = getattr(ip, "regex_v" + ip_type)(index[6:])
+        else:
+            value = getattr(ip, index + "_v" + ip_type)()
+    except Exception as e:
+        error("Failed to get %s address: %s", ip_type, e)
+    return value
+
+
+def change_dns_record(dns, proxy_list, **kw):
+    for proxy in proxy_list:
+        if not proxy or (proxy.upper() in ['DIRECT', 'NONE']):
+            dns.Config.PROXY = None
+        else:
+            dns.Config.PROXY = proxy
+        record_type, domain = kw['record_type'], kw['domain']
+        info("%s(%s) ==> %s [via %s]", domain, record_type, kw['ip'], proxy)
+        try:
+            return dns.update_record(domain, kw['ip'], record_type=record_type)
+        except Exception as e:
+            error("Failed to update %s record for %s: %s", record_type, domain, e)
+    return False
+
+
+def update_ip(ip_type, cache, dns, proxy_list):
+    """
+    更新IP
+    """
+    ipname = 'ipv' + ip_type
+    domains = get_config(ipname)
+    if not domains:
+        return None
+    if not isinstance(domains, list):
+        domains = domains.strip('; ').replace(',', ';').replace(' ', ';').split(';')
+
+    index_rule = get_config('index' + ip_type, "default")
+    address = get_ip(ip_type, index_rule)
+    if not address:
+        error('Fail to get %s address!', ipname)
+        return False
+
+    if cache and (address == cache.get(ipname)):
+        info('%s address not changed, using cache.', ipname)
+        return True
+
+    record_type = 'A' if ip_type == '4' else 'AAAA'
+    update_success = False
+    for domain in domains:
+        domain = domain.lower()
+        if change_dns_record(dns, proxy_list, domain=domain, ip=address, record_type=record_type):
+            update_success = True
+
+    if isinstance(cache, dict):
+        cache[ipname] = update_success and address
+
+    return update_success
+
+
+def main():
+    """
+    更新
+    """
+    encode = sys.stdout.encoding
+    if encode is not None and encode.lower() != 'utf-8' and hasattr(sys.stdout, 'buffer'):
+        # 兼容windows 和部分ASCII编码的老旧系统
+        sys.stdout = TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
+        sys.stderr = TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
+    init_config(__description__, __doc__, version)
+
+    log_level = get_config('log.level')
+    log_format = get_config('log.format', '%(asctime)s %(levelname)s [%(module)s]: %(message)s')
+    # Override log format in debug mode to include filename and line number for detailed debugging
+    if (log_level == DEBUG or log_level == NOTSET) and not log_format:
+        log_format = '%(asctime)s %(levelname)s [%(filename)s:%(lineno)d]: %(message)s'
+    basicConfig(
+        level=log_level,
+        format=log_format,
+        datefmt=get_config('log.datefmt', '%Y-%m-%dT%H:%M:%S'),
+        filename=get_config('log.file'),
+    )
+
+    info("DDNS[ %s ] run: %s %s", version, os_name, sys.platform)
+
+    # Dynamically import the dns module as configuration
+    dns_provider = str(get_config('dns', 'dnspod').lower())
+    # dns_module = __import__(
+    #     '.dns', fromlist=[dns_provider], package=__package__)
+    dns = getattr(__import__('ddns.provider', fromlist=[dns_provider]), dns_provider)
+    # dns = getattr(dns_module, dns_provider)
+    dns.Config.ID = get_config('id')
+    dns.Config.TOKEN = get_config('token')
+    dns.Config.TTL = get_config('ttl')
+
+    if get_config("config"):
+        info('loaded Config from: %s', path.abspath(get_config('config')))
+
+    proxy = get_config('proxy') or 'DIRECT'
+    proxy_list = proxy if isinstance(
+        proxy, list) else proxy.strip(';').replace(',', ';').split(';')
+
+    cache_config = get_config('cache', True)
+    if cache_config is False:
+        cache = cache_config
+    elif cache_config is True:
+        cache = Cache(path.join(gettempdir(), 'ddns.cache'))
+    else:
+        cache = Cache(cache_config)
+
+    if cache is False:
+        info('Cache is disabled!')
+    elif not get_config('config_modified_time') or get_config('config_modified_time') >= cache.time:
+        warning('Cache file is outdated.')
+        cache.clear()
+    else:
+        debug('Cache is empty.')
+    update_ip('4', cache, dns, proxy_list)
+    update_ip('6', cache, dns, proxy_list)
+
+
+if __name__ == '__main__':
+    main()

+ 0 - 0
dns/__init__.py → ddns/provider/__init__.py


+ 0 - 0
dns/alidns.py → ddns/provider/alidns.py


+ 0 - 0
dns/callback.py → ddns/provider/callback.py


+ 0 - 0
dns/cloudflare.py → ddns/provider/cloudflare.py


+ 0 - 0
dns/dnscom.py → ddns/provider/dnscom.py


+ 0 - 0
dns/dnspod.py → ddns/provider/dnspod.py


+ 1 - 1
dns/dnspod_com.py → ddns/provider/dnspod_com.py

@@ -6,7 +6,7 @@ http://www.dnspod.com/docs/domains.html
 @author: New Future
 """
 
-from dns.dnspod import *  # noqa: F403
+from .dnspod import *  # noqa: F403
 
 API.SITE = "api.dnspod.com"  # noqa: F405
 API.DEFAULT = "default"  # noqa: F405

+ 0 - 0
dns/he.py → ddns/provider/he.py


+ 0 - 0
dns/huaweidns.py → ddns/provider/huaweidns.py


+ 0 - 0
util/__init__.py → ddns/util/__init__.py


+ 0 - 0
util/cache.py → ddns/util/cache.py


+ 294 - 0
ddns/util/config.py

@@ -0,0 +1,294 @@
+#!/usr/bin/env python
+# -*- coding:utf-8 -*-
+from argparse import Action, ArgumentParser, Namespace, RawTextHelpFormatter
+from json import load as loadjson, dump as dumpjson
+from os import stat, environ, path
+from logging import error, getLevelName
+from ast import literal_eval
+
+import sys
+
+
+__cli_args = Namespace()
+__config = {}  # type: dict
+log_levels = [
+    "CRITICAL",
+    "FATAL",
+    "ERROR",
+    "WARN",
+    "WARNING",
+    "INFO",
+    "DEBUG",
+    "NOTSET",
+]
+
+# 支持数组的参数列表
+ARRAY_PARAMS = ["index4", "index6", "ipv4", "ipv6", "proxy"]
+# 简单数组,支持’,’, ‘;’ 分隔的参数列表
+SIMPLE_ARRAY_PARAMS = ["ipv4", "ipv6", "proxy"]
+
+
+def str2bool(v):
+    """
+    parse string to boolean
+    """
+    if isinstance(v, bool):
+        return v
+    if v.lower() in ("yes", "true", "t", "y", "1"):
+        return True
+    elif v.lower() in ("no", "false", "f", "n", "0"):
+        return False
+    else:
+        return v
+
+
+def log_level(value):
+    """
+    parse string to log level
+    """
+    return getLevelName(value.upper())
+
+
+def parse_array_string(value, enable_simple_split):
+    """
+    解析数组字符串
+    仅当 trim 之后以 '[' 开头以 ']' 结尾时,才尝试使用 ast.literal_eval 解析
+    默认返回原始字符串
+    """
+    if not isinstance(value, str):
+        return value
+
+    trimmed = value.strip()
+    if trimmed.startswith("[") and trimmed.endswith("]"):
+        try:
+            # 尝试使用 ast.literal_eval 解析数组
+            parsed_value = literal_eval(trimmed)
+            # 确保解析结果是列表或元组
+            if isinstance(parsed_value, (list, tuple)):
+                return list(parsed_value)
+        except (ValueError, SyntaxError) as e:
+            # 解析失败时返回原始字符串
+            error("Failed to parse array string: %s. Exception: %s", value, e)
+    elif enable_simple_split and "," in trimmed:
+        # 尝试使用逗号或分号分隔符解析
+        return [item.strip() for item in trimmed.split(",") if item.strip()]
+    return value
+
+
+def init_config(description, doc, version):
+    """
+    配置
+    """
+    global __cli_args
+    parser = ArgumentParser(
+        description=description, epilog=doc, formatter_class=RawTextHelpFormatter
+    )
+    parser.add_argument("-v", "--version", action="version", version=version)
+    parser.add_argument(
+        "-c", "--config", metavar="FILE", help="load config file [配置文件路径]"
+    )
+    parser.add_argument(
+        "--debug",
+        action="store_true",
+        help="debug mode [调试模式等效 --log.level=DEBUG]",
+    )
+
+    # 参数定义
+    parser.add_argument(
+        "--dns",
+        help="DNS provider [DNS服务提供商]",
+        choices=[
+            "alidns",
+            "cloudflare",
+            "dnscom",
+            "dnspod",
+            "dnspod_com",
+            "he",
+            "huaweidns",
+            "callback",
+        ],
+    )
+    parser.add_argument("--id", help="API ID or email [对应账号ID或邮箱]")
+    parser.add_argument("--token", help="API token or key [授权凭证或密钥]")
+    parser.add_argument(
+        "--index4",
+        nargs="*",
+        action=ExtendAction,
+        metavar="RULE",
+        help="IPv4 rules [获取IPv4方式, 多次可配置多规则]",
+    )
+    parser.add_argument(
+        "--index6",
+        nargs="*",
+        action=ExtendAction,
+        metavar="RULE",
+        help="IPv6 rules [获取IPv6方式, 多次可配置多规则]",
+    )
+    parser.add_argument(
+        "--ipv4",
+        nargs="*",
+        action=ExtendAction,
+        metavar="DOMAIN",
+        help="IPv4 domains [IPv4域名列表, 可配置多个域名]",
+    )
+    parser.add_argument(
+        "--ipv6",
+        nargs="*",
+        action=ExtendAction,
+        metavar="DOMAIN",
+        help="IPv6 domains [IPv6域名列表, 可配置多个域名]",
+    )
+    parser.add_argument("--ttl", type=int, help="DNS TTL(s) [设置域名解析过期时间]")
+    parser.add_argument(
+        "--proxy",
+        nargs="*",
+        action=ExtendAction,
+        help="HTTP proxy [设置http代理,可配多个代理连接]",
+    )
+    parser.add_argument(
+        "--cache",
+        type=str2bool,
+        nargs="?",
+        const=True,
+        help="set cache [启用缓存开关,或传入保存路径]",
+    )
+    parser.add_argument(
+        "--no-cache",
+        dest="cache",
+        action="store_const",
+        const=False,
+        help="disable cache [关闭缓存等效 --cache=false]",
+    )
+    parser.add_argument(
+        "--log.file", metavar="FILE", help="log file [日志文件,默认标准输出]"
+    )
+    parser.add_argument("--log.level", type=log_level, metavar="|".join(log_levels))
+    parser.add_argument(
+        "--log.format", metavar="FORMAT", help="log format [设置日志打印格式]"
+    )
+    parser.add_argument(
+        "--log.datefmt", metavar="FORMAT", help="date format [日志时间打印格式]"
+    )
+
+    __cli_args = parser.parse_args()
+    if __cli_args.debug:
+        # 如果启用调试模式,则设置日志级别为 DEBUG
+        setattr(__cli_args, "log.level", log_level("DEBUG"))
+
+    is_configfile_required = not get_config("token") and not get_config("id")
+    config_file = get_config("config")
+    if not config_file:
+        # 未指定配置文件且需要读取文件时,依次查找
+        cfgs = [
+            path.abspath("config.json"),
+            path.expanduser("~/.ddns/config.json"),
+            "/etc/ddns/config.json",
+        ]
+        config_file = next((cfg for cfg in cfgs if path.isfile(cfg)), cfgs[0])
+
+    if path.isfile(config_file):
+        __load_config(config_file)
+        __cli_args.config = config_file
+    elif is_configfile_required:
+        error("Config file is required, but not found: %s", config_file)
+        # 如果需要配置文件但没有指定,则自动生成
+        if generate_config(config_file):
+            sys.stdout.write("Default configure file %s is generated.\n" % config_file)
+            sys.exit(1)
+        else:
+            sys.exit("fail to load config from file: %s\n" % config_file)
+
+
+def __load_config(config_path):
+    """
+    加载配置
+    """
+    global __config
+    try:
+        with open(config_path, "r") as configfile:
+            __config = loadjson(configfile)
+            __config["config_modified_time"] = stat(config_path).st_mtime
+            if "log" in __config:
+                if "level" in __config["log"] and __config["log"]["level"] is not None:
+                    __config["log.level"] = log_level(__config["log"]["level"])
+                if "file" in __config["log"]:
+                    __config["log.file"] = __config["log"]["file"]
+                if "format" in __config["log"]:
+                    __config["log.format"] = __config["log"]["format"]
+                if "datefmt" in __config["log"]:
+                    __config["log.datefmt"] = __config["log"]["datefmt"]
+            elif "log.level" in __config:
+                __config["log.level"] = log_level(__config["log.level"])
+    except Exception as e:
+        error("Failed to load config file `%s`: %s", config_path, e)
+        raise
+        # 重新抛出异常
+
+
+def get_config(key, default=None):
+    """
+    读取配置
+    1. 命令行参数
+    2. 配置文件
+    3. 环境变量
+    """
+    if hasattr(__cli_args, key) and getattr(__cli_args, key) is not None:
+        return getattr(__cli_args, key)
+    if key in __config:
+        return __config.get(key)
+    # 检查环境变量
+    env_name = "DDNS_" + key.replace(".", "_")  # type:str
+    variations = [env_name, env_name.upper(), env_name.lower()]
+    value = next((environ.get(v) for v in variations if v in environ), None)
+
+    # 如果找到环境变量值且参数支持数组,尝试解析为数组
+    if value is not None and key in ARRAY_PARAMS:
+        return parse_array_string(value, key in SIMPLE_ARRAY_PARAMS)
+
+    return value if value is not None else default
+
+
+class ExtendAction(Action):
+    """
+    兼容 Python <3.8 的 extend action
+    """
+
+    def __call__(self, parser, namespace, values, option_string=None):
+        items = getattr(namespace, self.dest, None)
+        if items is None:
+            items = []
+        # values 可能是单个值或列表
+        if isinstance(values, list):
+            items.extend(values)
+        else:
+            items.append(values)
+        setattr(namespace, self.dest, items)
+
+
+def generate_config(config_path):
+    """
+    生成配置文件
+    """
+    configure = {
+        "$schema": "https://ddns.newfuture.cc/schema/v4.0.json",
+        "id": "YOUR ID or EMAIL for DNS Provider",
+        "token": "YOUR TOKEN or KEY for DNS Provider",
+        "dns": "dnspod",
+        "ipv4": ["newfuture.cc", "ddns.newfuture.cc"],
+        "ipv6": ["newfuture.cc", "ipv6.ddns.newfuture.cc"],
+        "index4": "default",
+        "index6": "default",
+        "ttl": None,
+        "proxy": None,
+        "log": {"level": "INFO"},
+    }
+    try:
+        with open(config_path, "w") as f:
+            dumpjson(configure, f, indent=2, sort_keys=True)
+            return True
+    except IOError:
+        error("Cannot open config file to write: `%s`!", config_path)
+        return False
+    except Exception as e:
+        error("Failed to write config file `%s`: %s", config_path, e)
+        return False

+ 0 - 0
util/ip.py → ddns/util/ip.py


+ 6 - 3
docker/Dockerfile

@@ -24,14 +24,17 @@ FROM alpine:${HOST_VERSION} AS base
 RUN find /lib /usr/lib /usr/local/lib -name '*.so*' | sed 's|.*/||' | awk '{print "--noinclude-dlls="$0}' > nuitka_exclude_so.txt
 
 FROM ${BUILDER} AS builder
-COPY . .
+COPY run.py .github/patch.py .
+COPY ddns ddns
 COPY --from=base /nuitka_exclude_so.txt nuitka_exclude_so.txt
-RUN python3 .github/patch.py
+ARG GITHUB_REF_NAME
+ENV GITHUB_REF_NAME=${GITHUB_REF_NAME}
+RUN python3 patch.py
 RUN python3 -O -m nuitka run.py \
     --remove-output \
     --lto=yes \
     $(cat nuitka_exclude_so.txt)
-RUN mkdir /output && cp ddns /output/
+RUN mkdir /output && cp dist/ddns /output/
 COPY docker/entrypoint.sh /output/
 
 FROM alpine:${HOST_VERSION}

+ 7 - 4
docker/glibc.Dockerfile

@@ -26,17 +26,20 @@ WORKDIR /app
 
 FROM ${BUILDER} AS builder
 # 拷贝项目文件
-COPY . .
-RUN python3 .github/patch.py
+COPY run.py .github/patch.py doc/img/ddns.svg .
+COPY ddns ddns
+ARG GITHUB_REF_NAME
+ENV GITHUB_REF_NAME=${GITHUB_REF_NAME}
+RUN python3 patch.py
 # 构建二进制文件,glibc arm下编译会报错,
 # collect2: fatal error: ld terminated with signal 11 [Segmentation fault], core dumped compilation terminated.
 # FATAL: Error, the C compiler 'gcc' crashed with segfault. Consider upgrading it or using '--clang' option.
 RUN apt-get update && apt-get install -y --no-install-recommends clang
 RUN python3 -O -m nuitka run.py \
     --remove-output \
-    --linux-icon=doc/img/ddns.svg \
+    --linux-icon=ddns.svg \
     $( [ "$(uname -m)" = "aarch64" ] || echo --lto=yes )
-RUN cp ddns /bin/ddns && cp ddns /ddns
+RUN cp dist/ddns /bin/ddns && cp dist/ddns /ddns
 
 
 # export the binary

+ 6 - 3
docker/musl.Dockerfile

@@ -20,12 +20,15 @@ WORKDIR /app
 
 
 FROM ${BUILDER} AS builder
-COPY . .
-RUN python3 .github/patch.py
+COPY run.py .github/patch.py .
+COPY ddns ddns
+ARG GITHUB_REF_NAME
+ENV GITHUB_REF_NAME=${GITHUB_REF_NAME}
+RUN python3 patch.py
 RUN python3 -O -m nuitka run.py \
     --remove-output \
     --lto=yes
-RUN cp ddns /bin/ddns && cp ddns /ddns
+RUN cp dist/ddns /bin/ddns && cp dist/ddns /ddns
 
 
 # export the binary

+ 112 - 0
pyproject.toml

@@ -0,0 +1,112 @@
+[build-system]
+requires = ["setuptools>=64.0", "wheel", "setuptools_scm"]
+build-backend = "setuptools.build_meta"
+[tool.setuptools_scm]
+
+[project]
+name = "ddns"
+dynamic = ["version"]
+description = "Dynamic DNS client for multiple providers, supporting IPv4 and IPv6."
+authors = [{ name = "NewFuture", email = "[email protected]" }]
+readme = "README.md"
+license = { text = "MIT" }
+requires-python = ">=2.7"
+classifiers = [
+  "Development Status :: 5 - Production/Stable",
+  "Intended Audience :: Developers",
+  "Intended Audience :: End Users/Desktop",
+  "Intended Audience :: Information Technology",
+  "Intended Audience :: System Administrators",
+  "Topic :: Internet",
+  "Topic :: Internet :: Name Service (DNS)",
+  "Topic :: System :: Networking",
+  "Topic :: Software Development",
+  "License :: OSI Approved :: MIT License",
+  'Programming Language :: Python :: 2.7',
+  "Programming Language :: Python :: 3",
+  "Programming Language :: Python :: 3.6",
+  "Programming Language :: Python :: 3.7",
+  "Programming Language :: Python :: 3.8",
+  "Programming Language :: Python :: 3.9",
+  "Programming Language :: Python :: 3.10",
+  "Programming Language :: Python :: 3.11",
+  "Programming Language :: Python :: 3.12",
+  "Programming Language :: Python :: 3.13",
+]
+keywords = ["ddns", "ipv6", "ipv4", "dns", "dnspod", "alidns", "cloudflare"]
+dependencies = []
+
+[project.urls]
+Homepage = "https://ddns.newfuture.cc"
+Documentation = "https://ddns.newfuture.cc"
+Repository = "https://github.com/NewFuture/DDNS"
+"Bug Tracker" = "https://github.com/NewFuture/DDNS/issues"
+Source = "https://github.com/NewFuture/DDNS"
+
+[project.scripts]
+ddns = "ddns.__main__:main"
+
+# Optional dependencies
+#[project.optional-dependencies]
+#dev = [
+#   "pytest>=6.0",
+#    "pytest-cov",
+#    "black",
+#    "flake8"
+#]
+
+# Setuptools configuration
+[tool.setuptools]
+platforms = ["any"]
+packages = ["ddns"]
+package-dir= {"ddns" = "ddns"}
+py-modules = ["ddns","ddns.provider", "ddns.util"]
+
+#[tool.setuptools.packages.find]
+#where = ["."]
+
+
+
+# [tool.setuptools.dynamic]
+# version = { attr = "ddns.__version__" }
+# description = { attr = "ddns.__description__" }
+
+
+# 测试配置
+#[tool.pytest.ini_options]
+#testpaths = ["tests"]
+#python_files = ["test_*.py"]
+#python_classes = ["Test*"]
+#python_functions = ["test_*"]
+#addopts = [
+#    "--strict-markers",
+#    "--strict-config",
+#    "--cov=ddns",
+#    "--cov-report=term-missing",
+#]
+
+# 代码格式化配置
+[tool.black]
+line-length = 100
+target-version = ['py38']
+include = '\.py$'
+extend-exclude = '''
+/(
+  # directories
+  \.eggs
+  | \.git
+  | \.hg
+  | \.mypy_cache
+  | \.tox
+  | \.venv
+  | build
+  | dist
+)/
+'''
+
+# 类型检查配置
+[tool.mypy]
+python_version = "3.8"
+warn_return_any = true
+warn_unused_configs = true
+disallow_untyped_defs = true

+ 5 - 180
run.py

@@ -1,189 +1,10 @@
 #!/usr/bin/env python
 # -*- coding:utf-8 -*-
-"""
-DDNS
-@author: New Future
-@modified: rufengsuixing
-"""
-
-from os import path, environ, name as os_name
-from io import TextIOWrapper
-from subprocess import check_output
-from tempfile import gettempdir
-from logging import basicConfig, info, warning, error, debug, DEBUG, NOTSET
-
-import sys
-
-from util import ip
-from util.cache import Cache
-from util.config import init_config, get_config
-
-__version__ = "${BUILD_VERSION}@${BUILD_DATE}"  # CI 时会被Tag替换
-__description__ = "automatically update DNS records to my IP [域名自动指向本机IP]"
-__doc__ = """
-ddns[%s]
-(i) homepage or docs [文档主页]: https://ddns.newfuture.cc/
-(?) issues or bugs [问题和帮助]: https://github.com/NewFuture/DDNS/issues
-Copyright (c) New Future (MIT License)
-""" % (__version__)
-
-environ["DDNS_VERSION"] = "${BUILD_VERSION}"
-
-
-def is_false(value):
-    """
-    判断值是否为 False
-    字符串 'false', 或者 False, 或者 'none';
-    0 不是 False
-    """
-    if isinstance(value, str):
-        return value.strip().lower() in ['false', 'none']
-    return value is False
-
-
-def get_ip(ip_type, index="default"):
-    """
-    get IP address
-    """
-    # CN: 捕获异常
-    # EN: Catch exceptions
-    value = None
-    try:
-        debug("get_ip(%s, %s)", ip_type, index)
-        if is_false(index):  # disabled
-            return False
-        elif isinstance(index, list):  # 如果获取到的规则是列表,则依次判断列表中每一个规则,直到获取到IP
-            for i in index:
-                value = get_ip(ip_type, i)
-                if value:
-                    break
-        elif str(index).isdigit():  # 数字 local eth
-            value = getattr(ip, "local_v" + ip_type)(index)
-        elif index.startswith('cmd:'):  # cmd
-            value = str(check_output(index[4:]).strip().decode('utf-8'))
-        elif index.startswith('shell:'):  # shell
-            value = str(check_output(
-                index[6:], shell=True).strip().decode('utf-8'))
-        elif index.startswith('url:'):  # 自定义 url
-            value = getattr(ip, "public_v" + ip_type)(index[4:])
-        elif index.startswith('regex:'):  # 正则 regex
-            value = getattr(ip, "regex_v" + ip_type)(index[6:])
-        else:
-            value = getattr(ip, index + "_v" + ip_type)()
-    except Exception as e:
-        error("Failed to get %s address: %s", ip_type, e)
-    return value
-
-
-def change_dns_record(dns, proxy_list, **kw):
-    for proxy in proxy_list:
-        if not proxy or (proxy.upper() in ['DIRECT', 'NONE']):
-            dns.Config.PROXY = None
-        else:
-            dns.Config.PROXY = proxy
-        record_type, domain = kw['record_type'], kw['domain']
-        info("%s(%s) ==> %s [via %s]", domain, record_type, kw['ip'], proxy)
-        try:
-            return dns.update_record(domain, kw['ip'], record_type=record_type)
-        except Exception as e:
-            error("Failed to update %s record for %s: %s", record_type, domain, e)
-    return False
-
-
-def update_ip(ip_type, cache, dns, proxy_list):
-    """
-    更新IP
-    """
-    ipname = 'ipv' + ip_type
-    domains = get_config(ipname)
-    if not domains:
-        return None
-    if not isinstance(domains, list):
-        domains = domains.strip('; ').replace(
-            ',', ';').replace(' ', ';').split(';')
-    index_rule = get_config('index' + ip_type, "default")  # 从配置中获取index配置
-    address = get_ip(ip_type, index_rule)
-    if not address:
-        error('Fail to get %s address!', ipname)
-        return False
-    elif cache and (address == cache[ipname]):
-        info('%s address not changed, using cache.', ipname)
-        return True
-    record_type = (ip_type == '4') and 'A' or 'AAAA'
-    update_fail = False  # https://github.com/NewFuture/DDNS/issues/16
-    for domain in domains:
-        domain = domain.lower()  # https://github.com/NewFuture/DDNS/issues/431
-        if change_dns_record(dns, proxy_list, domain=domain, ip=address, record_type=record_type):
-            update_fail = True
-    if cache is not False:
-        # 如果更新失败删除缓存
-        cache[ipname] = update_fail and address
-
-
-def main():
-    """
-    更新
-    """
-    init_config(__description__, __doc__, __version__)
-
-    log_level = get_config('log.level')
-    log_format = get_config('log.format', '%(asctime)s %(levelname)s [%(module)s]: %(message)s')
-    # Override log format in debug mode to include filename and line number for detailed debugging
-    if (log_level == DEBUG or log_level == NOTSET) and not log_format:
-        log_format = '%(asctime)s %(levelname)s [%(filename)s:%(lineno)d]: %(message)s'
-    basicConfig(
-        level=log_level,
-        format=log_format,
-        datefmt=get_config('log.datefmt', '%Y-%m-%dT%H:%M:%S'),
-        filename=get_config('log.file'),
-    )
-
-    info("DDNS[ %s ] run: %s %s", __version__, os_name, sys.platform)
-
-    # Dynamically import the dns module as configuration
-    dns_provider = str(get_config('dns', 'dnspod').lower())
-    dns = getattr(__import__('dns', fromlist=[dns_provider]), dns_provider)
-    dns.Config.ID = get_config('id')
-    dns.Config.TOKEN = get_config('token')
-    dns.Config.TTL = get_config('ttl')
-
-    if get_config("config"):
-        info('loaded Config from: %s', path.abspath(get_config('config')))
-
-    proxy = get_config('proxy') or 'DIRECT'
-    proxy_list = proxy if isinstance(
-        proxy, list) else proxy.strip(';').replace(',', ';').split(';')
-
-    cache_config = get_config('cache', True)
-    if cache_config is False:
-        cache = cache_config
-    elif cache_config is True:
-        cache = Cache(path.join(gettempdir(), 'ddns.cache'))
-    else:
-        cache = Cache(cache_config)
-
-    if cache is False:
-        info('Cache is disabled!')
-    elif not get_config('config_modified_time') or get_config('config_modified_time') >= cache.time:
-        warning('Cache file is outdated.')
-        cache.clear()
-    else:
-        debug('Cache is empty.')
-    update_ip('4', cache, dns, proxy_list)
-    update_ip('6', cache, dns, proxy_list)
-
-
-if __name__ == '__main__':
-    encode = sys.stdout.encoding
-    if encode is not None and encode.lower() != 'utf-8' and hasattr(sys.stdout, 'buffer'):
-        # 兼容windows 和部分ASCII编码的老旧系统
-        sys.stdout = TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
-        sys.stderr = TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
-    main()
 
 # Nuitka Project Configuration
 # nuitka-project: --mode=onefile
 # nuitka-project: --output-filename=ddns
+# nuitka-project: --output-dir=dist
 # nuitka-project: --product-name=DDNS
 # nuitka-project: --product-version=0.0.0
 # nuitka-project: --onefile-tempdir-spec="{TEMP}/{PRODUCT}_{VERSION}"
@@ -194,3 +15,7 @@ if __name__ == '__main__':
 # nuitka-project: --python-flag=no_site,no_asserts,no_docstrings,isolated,static_hashes
 # nuitka-project: --nofollow-import-to=tkinter,unittest,pydoc,doctest,distutils,setuptools,lib2to3,test,idlelib,lzma
 # nuitka-project: --noinclude-dlls=liblzma.*
+
+from ddns.__main__ import main
+
+main()

+ 0 - 13
setup.cfg

@@ -1,13 +0,0 @@
-[metadata]
-# This includes the license file(s) in the wheel.
-# https://wheel.readthedocs.io/en/stable/user_guide.html#including-license-files-in-the-generated-wheel-file
-license_files = LICENSE
-
-[bdist_wheel]
-# This flag says to generate wheels that support both Python 2 and Python
-# 3. If your code will not run unchanged on both Python 2 and 3, you will
-# need to generate separate wheels for each Python version that you
-# support. Removing this line (or setting universal to 0) will prevent
-# bdist_wheel from trying to make a universal wheel. For more see:
-# https://packaging.python.org/guides/distributing-packages-using-setuptools/#wheels
-universal=1

+ 0 - 240
setup.py

@@ -1,240 +0,0 @@
-#!/usr/bin/env python
-# -*- coding:utf-8 -*-
-
-"""A setuptools based setup module.
-
-See:
-https://packaging.python.org/guides/distributing-packages-using-setuptools/
-https://github.com/pypa/sampleproject
-"""
-
-from time import time
-from os import path, environ
-# io.open is needed for projects that support Python 2.7
-# It ensures open() defaults to text mode with universal newlines,
-# and accepts an argument to specify the text encoding
-# Python 3 only projects can skip this import
-from io import open
-# Always prefer setuptools over distutils
-from setuptools import setup, find_packages
-from run import __description__
-
-here = path.abspath(path.dirname(__file__))
-
-# Clean dist after binary build
-if path.isfile('dist/ddns') or path.isfile('dist/ddns.exe') or path.isfile('dist/ddns-osx'):
-    from shutil import rmtree
-    rmtree('dist/')
-
-# Get the long description from the README file
-with open(path.join(here, 'README.md'), encoding='utf-8') as f:
-    long_description = f.read()
-
-# Get version from environment vars
-if 'TRAVIS_TAG' in environ:
-    version = environ['TRAVIS_TAG']  # `TRAVIS_TAG` from Travis
-elif 'BUILD_SOURCEBRANCHNAME' in environ:
-    version = environ['BUILD_SOURCEBRANCHNAME']  # from azure pipelines
-elif 'GITHUB_REF_NAME' in environ:  # github actions
-    ref = environ['GITHUB_REF_NAME']
-    if ref == 'master' or ref == 'main':  # CI
-        version = '4.0.b' + str(int(time()))
-    elif ref.startswith('v') or ref.startswith('V'):  # Tag
-        version = ref
-    else:  # PR
-        version = '0.0.a1'
-else:
-    raise Exception("setup.py should be run in CI (Travis or AzurePipelines)")
-version = version.strip('v').strip('V')
-
-# Arguments marked as "Required" below must be included for upload to PyPI.
-# Fields marked as "Optional" may be commented out.
-
-setup(
-    # This is the name of your project. The first time you publish this
-    # package, this name will be registered for you. It will determine how
-    # users can install this project, e.g.:
-    #
-    # $ pip install sampleproject
-    #
-    # And where it will live on PyPI: https://pypi.org/project/sampleproject/
-    #
-    # There are some restrictions on what makes a valid project name
-    # specification here:
-    # https://packaging.python.org/specifications/core-metadata/#name
-    name='ddns',  # Required
-
-    # Versions should comply with PEP 440:
-    # https://www.python.org/dev/peps/pep-0440/
-    #
-    # For a discussion on single-sourcing the version across setup.py and the
-    # project code, see
-    # https://packaging.python.org/en/latest/single_source_version.html
-    version=version,  # Required
-
-    # This is a one-line description or tagline of what your project does. This
-    # corresponds to the "Summary" metadata field:
-    # https://packaging.python.org/specifications/core-metadata/#summary
-    # Optional
-    description=__description__,
-
-    # This is an optional longer description of your project that represents
-    # the body of text which users will see when they visit PyPI.
-    #
-    # Often, this is the same as your README, so you can just read it in from
-    # that file directly (as we have already done above)
-    #
-    # This field corresponds to the "Description" metadata field:
-    # https://packaging.python.org/specifications/core-metadata/#description-optional
-    long_description=long_description,  # Optional
-
-    # Denotes that our long_description is in Markdown; valid values are
-    # text/plain, text/x-rst, and text/markdown
-    #
-    # Optional if long_description is written in reStructuredText (rst) but
-    # required for plain-text or Markdown; if unspecified, "applications should
-    # attempt to render [the long_description] as text/x-rst; charset=UTF-8 and
-    # fall back to text/plain if it is not valid rst" (see link below)
-    #
-    # This field corresponds to the "Description-Content-Type" metadata field:
-    # https://packaging.python.org/specifications/core-metadata/#description-content-type-optional
-    long_description_content_type='text/markdown',  # Optional (see note above)
-
-    # This should be a valid link to your project's main homepage.
-    #
-    # This field corresponds to the "Home-Page" metadata field:
-    # https://packaging.python.org/specifications/core-metadata/#home-page-optional
-    url='https://ddns.newfuture.cc',  # Optional
-
-    # This should be your name or the name of the organization which owns the
-    # project.
-    author='NewFuture',  # Optional
-
-    # This should be a valid email address corresponding to the author listed
-    # above.
-    author_email='[email protected]',  # Optional
-
-    # Classifiers help users find your project by categorizing it.
-    #
-    # For a list of valid classifiers, see https://pypi.org/classifiers/
-    classifiers=[  # Optional
-        # How mature is this project? Common values are
-        #   3 - Alpha
-        #   4 - Beta
-        #   5 - Production/Stable
-        'Development Status :: 5 - Production/Stable',
-
-        # Indicate who your project is intended for
-        'Intended Audience :: Developers',
-        'Intended Audience :: End Users/Desktop',
-        'Intended Audience :: Information Technology',
-        'Intended Audience :: System Administrators',
-
-        # Topics
-        'Topic :: Internet',
-        'Topic :: Internet :: Name Service (DNS)',
-        'Topic :: System :: Networking',
-        'Topic :: Software Development',
-
-        # Pick your license as you wish
-        'License :: OSI Approved :: MIT License',
-
-        # Specify the Python versions you support here. In particular, ensure
-        # that you indicate whether you support Python 2, Python 3 or both.
-        # These classifiers are *not* checked by 'pip install'. See instead
-        # 'python_requires' below.
-        'Programming Language :: Python :: 2.7',
-        'Programming Language :: Python :: 3',
-    ],
-
-    # This field adds keywords for your project which will appear on the
-    # project page. What does your project relate to?
-    #
-    # Note that this is a string of words separated by whitespace, not a list.
-    keywords='ddns ipv6 ipv4 dns dnspod alidns cloudflare',  # Optional
-
-    # You can just specify package directories manually here if your project is
-    # simple. Or you can use find_packages().
-    #
-    # Alternatively, if you just want to distribute a single Python file, use
-    # the `py_modules` argument instead as follows, which will expect a file
-    # called `my_module.py` to exist:
-    #
-    py_modules=["run"],
-    #
-    packages=find_packages(
-        exclude=['contrib', 'docs', 'tests', 'dist']),  # Required
-
-    # Specify which Python versions you support. In contrast to the
-    # 'Programming Language' classifiers above, 'pip install' will check this
-    # and refuse to install the project if the version does not match. If you
-    # do not support Python 2, you can simplify this to '>=3.5' or similar, see
-    # https://packaging.python.org/guides/distributing-packages-using-setuptools/#python-requires
-    python_requires='>=2.5, <4',
-
-    # This field lists other packages that your project depends on to run.
-    # Any package you put here will be installed by pip when your project is
-    # installed, so they must be valid existing projects.
-    #
-    # For an analysis of "install_requires" vs pip's requirements files see:
-    # https://packaging.python.org/en/latest/requirements.html
-    # install_requires=[],  # Optional
-
-    # List additional groups of dependencies here (e.g. development
-    # dependencies). Users will be able to install these using the "extras"
-    # syntax, for example:
-    #
-    #   $ pip install sampleproject[dev]
-    #
-    # Similar to `install_requires` above, these must be valid existing
-    # projects.
-    # extras_require={  # Optional
-    #     'dev': ['check-manifest'],
-    #     'test': ['coverage'],
-    # },
-
-    # If there are data files included in your packages that need to be
-    # installed, specify them here.
-    #
-    # If using Python 2.6 or earlier, then these have to be included in
-    # MANIFEST.in as well.
-    # package_data={  # Optional
-    #     'sample': ['package_data.dat'],
-    # },
-
-    # Although 'package_data' is the preferred approach, in some case you may
-    # need to place data files outside of your packages. See:
-    # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files
-    #
-    # In this case, 'data_file' will be installed into '<sys.prefix>/my_data'
-    # data_files=[('my_data', ['data/data_file'])],  # Optional//
-
-    # To provide executable scripts, use entry points in preference to the
-    # "scripts" keyword. Entry points provide cross-platform support and allow
-    # `pip` to create the appropriate form of executable for the target
-    # platform.
-    #
-    # For example, the following would provide a command called `sample` which
-    # executes the function `main` from this package when invoked:
-    entry_points={  # Optional
-        'console_scripts': [
-            'ddns=run:main',
-        ],
-    },
-
-    # List additional URLs that are relevant to your project as a dict.
-    #
-    # This field corresponds to the "Project-URL" metadata fields:
-    # https://packaging.python.org/specifications/core-metadata/#project-url-multiple-use
-    #
-    # Examples listed include a pattern for specifying where the package tracks
-    # issues, where the source is hosted, where to say thanks to the package
-    # maintainers, and where to support the project financially. The key is
-    # what's used to render the link text on PyPI.
-    project_urls={  # Optional
-        'Bug Reports': 'https://github.com/NewFuture/DDNS/issues',
-        'Source': 'https://github.com/NewFuture/DDNS',
-    },
-    license="MIT",
-    platforms=['any'],
-)

+ 0 - 240
util/config.py

@@ -1,240 +0,0 @@
-#!/usr/bin/env python
-# -*- coding:utf-8 -*-
-from argparse import Action, ArgumentParser, Namespace, RawTextHelpFormatter
-from json import load as loadjson, dump as dumpjson
-from os import stat, environ, path
-from logging import error, getLevelName
-from ast import literal_eval
-
-import sys
-
-
-__cli_args = Namespace()
-__config = {}  # type: dict
-log_levels = ['CRITICAL', 'FATAL', 'ERROR',
-              'WARN', 'WARNING', 'INFO', 'DEBUG', 'NOTSET']
-
-# 支持数组的参数列表
-ARRAY_PARAMS = ['index4', 'index6', 'ipv4', 'ipv6', 'proxy']
-# 简单数组,支持’,’, ‘;’ 分隔的参数列表
-SIMPLE_ARRAY_PARAMS = ['ipv4', 'ipv6', 'proxy']
-
-
-def str2bool(v):
-    """
-    parse string to boolean
-    """
-    if isinstance(v, bool):
-        return v
-    if v.lower() in ('yes', 'true', 't', 'y', '1'):
-        return True
-    elif v.lower() in ('no', 'false', 'f', 'n', '0'):
-        return False
-    else:
-        return v
-
-
-def log_level(value):
-    """
-    parse string to log level
-    """
-    return getLevelName(value.upper())
-
-
-def parse_array_string(value, enable_simple_split):
-    """
-    解析数组字符串
-    仅当 trim 之后以 '[' 开头以 ']' 结尾时,才尝试使用 ast.literal_eval 解析
-    默认返回原始字符串
-    """
-    if not isinstance(value, str):
-        return value
-
-    trimmed = value.strip()
-    if trimmed.startswith('[') and trimmed.endswith(']'):
-        try:
-            # 尝试使用 ast.literal_eval 解析数组
-            parsed_value = literal_eval(trimmed)
-            # 确保解析结果是列表或元组
-            if isinstance(parsed_value, (list, tuple)):
-                return list(parsed_value)
-        except (ValueError, SyntaxError) as e:
-            # 解析失败时返回原始字符串
-            error('Failed to parse array string: %s. Exception: %s', value, e)
-    elif enable_simple_split and ',' in trimmed:
-        # 尝试使用逗号或分号分隔符解析
-        return [item.strip() for item in trimmed.split(',') if item.strip()]
-    return value
-
-
-def init_config(description, doc, version):
-    """
-    配置
-    """
-    global __cli_args
-    parser = ArgumentParser(description=description,
-                            epilog=doc, formatter_class=RawTextHelpFormatter)
-    parser.add_argument('-v', '--version',
-                        action='version', version=version)
-    parser.add_argument('-c', '--config', help='run with config file [配置文件路径]')
-
-    # 参数定义
-    parser.add_argument('--dns', help='DNS Provider [DNS服务提供商]', choices=[
-                        'alidns', 'cloudflare', 'dnscom', 'dnspod', 'dnspod_com', 'he', 'huaweidns', 'callback'])
-    parser.add_argument('--id', help='api ID [授权账户]')
-    parser.add_argument('--token', help='api token or Secret key [授权访问凭证或密钥]')
-    parser.add_argument('--index4', nargs='*', action=ExtendAction,
-                        help='list to get ipv4 [IPV4 获取方式]')
-    parser.add_argument('--index6', nargs='*', action=ExtendAction,
-                        help='list to get ipv6 [IPV6获取方式]')
-    parser.add_argument('--ipv4', nargs='*', action=ExtendAction,
-                        help='ipv4 domain list [IPV4域名列表]')
-    parser.add_argument('--ipv6', nargs='*', action=ExtendAction,
-                        help='ipv6 domain list [IPV6域名列表]')
-    parser.add_argument('--ttl', type=int, help='ttl for DNS [DNS 解析 TTL 时间]')
-    parser.add_argument('--proxy', nargs='*', action=ExtendAction,
-                        help='https proxy [设置http 代理,多代理逐个尝试直到成功]')
-    parser.add_argument('--cache',  type=str2bool, nargs='?',
-                        const=True, help='cache flag [启用缓存,可配配置路径或开关]')
-    parser.add_argument('--debug', action='store_true',
-                        help='debug mode [调试模式,等同log.level=DEBUG]')
-    parser.add_argument('--log.file', metavar='LOG_FILE',
-                        help='log file [日志文件,默认标准输出]')
-    parser.add_argument('--log.level', type=log_level,
-                        metavar='|'.join(log_levels))
-    parser.add_argument('--log.format', metavar='LOG_FORMAT',
-                        help='log format [日志格式字符串]')
-    parser.add_argument('--log.datefmt', metavar='DATE_FORMAT',
-                        help='date format [日期格式字符串]')
-
-    __cli_args = parser.parse_args()
-    if __cli_args.debug:
-        # 如果启用调试模式,则设置日志级别为 DEBUG
-        setattr(__cli_args, 'log.level', log_level('DEBUG'))
-
-    is_configfile_required = not get_config("token") and not get_config("id")
-    config_file = get_config("config")
-    if not config_file:
-        # 未指定配置文件且需要读取文件时,依次查找
-        cfgs = [
-            path.abspath('config.json'),
-            path.expanduser('~/.ddns/config.json'),
-            '/etc/ddns/config.json'
-        ]
-        config_file = next((cfg for cfg in cfgs if path.isfile(cfg)), cfgs[0])
-
-    if path.isfile(config_file):
-        __load_config(config_file)
-        __cli_args.config = config_file
-    elif is_configfile_required:
-        error('Config file is required, but not found: %s', config_file)
-        # 如果需要配置文件但没有指定,则自动生成
-        if generate_config(config_file):
-            sys.stdout.write(
-                'Default configure file %s is generated.\n' % config_file)
-            sys.exit(1)
-        else:
-            sys.exit('fail to load config from file: %s\n' % config_file)
-
-
-def __load_config(config_path):
-    """
-    加载配置
-    """
-    global __config
-    try:
-        with open(config_path, 'r') as configfile:
-            __config = loadjson(configfile)
-            __config["config_modified_time"] = stat(config_path).st_mtime
-            if 'log' in __config:
-                if 'level' in __config['log'] and __config['log']['level'] is not None:
-                    __config['log.level'] = log_level(__config['log']['level'])
-                if 'file' in __config['log']:
-                    __config['log.file'] = __config['log']['file']
-                if 'format' in __config['log']:
-                    __config['log.format'] = __config['log']['format']
-                if 'datefmt' in __config['log']:
-                    __config['log.datefmt'] = __config['log']['datefmt']
-            elif 'log.level' in __config:
-                __config['log.level'] = log_level(__config['log.level'])
-    except Exception as e:
-        error('Failed to load config file `%s`: %s', config_path, e)
-        raise
-        # 重新抛出异常
-
-
-def get_config(key, default=None):
-    """
-    读取配置
-    1. 命令行参数
-    2. 配置文件
-    3. 环境变量
-    """
-    if hasattr(__cli_args, key) and getattr(__cli_args, key) is not None:
-        return getattr(__cli_args, key)
-    if key in __config:
-        return __config.get(key)
-    # 检查环境变量
-    env_name = 'DDNS_' + key.replace('.', '_')  # type:str
-    variations = [env_name, env_name.upper(), env_name.lower()]
-    value = next((environ.get(v) for v in variations if v in environ), None)
-
-    # 如果找到环境变量值且参数支持数组,尝试解析为数组
-    if value is not None and key in ARRAY_PARAMS:
-        return parse_array_string(value, key in SIMPLE_ARRAY_PARAMS)
-
-    return value if value is not None else default
-
-
-class ExtendAction(Action):
-    """
-    兼容 Python <3.8 的 extend action
-    """
-
-    def __call__(self, parser, namespace, values, option_string=None):
-        items = getattr(namespace, self.dest, None)
-        if items is None:
-            items = []
-        # values 可能是单个值或列表
-        if isinstance(values, list):
-            items.extend(values)
-        else:
-            items.append(values)
-        setattr(namespace, self.dest, items)
-
-
-def generate_config(config_path):
-    """
-    生成配置文件
-    """
-    configure = {
-        '$schema': 'https://ddns.newfuture.cc/schema/v4.0.json',
-        'id': 'YOUR ID or EMAIL for DNS Provider',
-        'token': 'YOUR TOKEN or KEY for DNS Provider',
-        'dns': 'dnspod',
-        'ipv4': [
-            'newfuture.cc',
-            'ddns.newfuture.cc'
-        ],
-        'ipv6': [
-            'newfuture.cc',
-            'ipv6.ddns.newfuture.cc'
-        ],
-        'index4': 'default',
-        'index6': 'default',
-        'ttl': None,
-        'proxy': None,
-        'log': {
-            'level': 'INFO'
-        }
-    }
-    try:
-        with open(config_path, 'w') as f:
-            dumpjson(configure, f, indent=2, sort_keys=True)
-            return True
-    except IOError:
-        error('Cannot open config file to write: `%s`!', config_path)
-        return False
-    except Exception as e:
-        error('Failed to write config file `%s`: %s', config_path, e)
-        return False