Browse Source

feat(config): 支持当行注释 Add JSON comment support with # and // styles for configuration files (#515)

* Add JSON comment support with # and // styles

Co-authored-by: NewFuture <[email protected]>

* Fix Python 2.7 compatibility and remove trailing whitespace

Co-authored-by: NewFuture <[email protected]>

* Fix lint issues and simplify comment removal logic

Co-authored-by: NewFuture <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: NewFuture <[email protected]>
Co-authored-by: New Future <[email protected]>
Copilot 6 months ago
parent
commit
d9b0b825d1
6 changed files with 393 additions and 8 deletions
  1. 1 1
      ddns/cache.py
  2. 5 2
      ddns/config/file.py
  3. 88 0
      ddns/util/comment.py
  4. 5 5
      tests/test_cache.py
  5. 46 0
      tests/test_config_file.py
  6. 248 0
      tests/test_util_comment.py

+ 1 - 1
ddns/cache.py

@@ -69,7 +69,7 @@ class Cache(dict):
             with open(self.__filename, "w") as data:
                 # 只保存非私有字段(不以__开头的字段)
                 filtered_data = {k: v for k, v in super(Cache, self).items() if not k.startswith("__")}
-                dump(filtered_data, data, separators=(',', ':'))
+                dump(filtered_data, data, separators=(",", ":"))
                 self.__logger.debug("save cache data to %s", self.__filename)
             self.__time = time()
             self.__changed = False

+ 5 - 2
ddns/config/file.py

@@ -7,6 +7,7 @@ from ast import literal_eval
 from io import open
 from json import loads as json_decode, dumps as json_encode
 from sys import stderr, stdout
+from ..util.comment import remove_comment
 
 
 def load_config(config_path):
@@ -31,9 +32,11 @@ def load_config(config_path):
     except Exception as e:
         stderr.write("Failed to load config file `%s`: %s\n" % (config_path, e))
         raise
-    # 优先尝试JSON解析
+    # 移除注释后尝试JSON解析
     try:
-        config = json_decode(content)
+        # 移除单行注释(# 和 // 风格)
+        content_without_comments = remove_comment(content)
+        config = json_decode(content_without_comments)
     except (ValueError, SyntaxError) as json_error:
         # JSON解析失败,尝试AST解析
         try:

+ 88 - 0
ddns/util/comment.py

@@ -0,0 +1,88 @@
+# -*- coding:utf-8 -*-
+"""
+Comment removal utility for JSON configuration files.
+Supports both # and // style single line comments.
+@author: GitHub Copilot
+"""
+
+
+def remove_comment(content):
+    # type: (str) -> str
+    """
+    移除字符串中的单行注释。
+    支持 # 和 // 两种注释风格。
+
+    Args:
+        content (str): 包含注释的字符串内容
+
+    Returns:
+        str: 移除注释后的字符串
+
+    Examples:
+        >>> remove_comment('{"key": "value"} // comment')
+        '{"key": "value"} '
+        >>> remove_comment('# This is a comment\\n{"key": "value"}')
+        '\\n{"key": "value"}'
+    """
+    if not content:
+        return content
+
+    lines = content.splitlines()
+    cleaned_lines = []
+
+    for line in lines:
+        # 移除行内注释,但要小心不要破坏字符串内的内容
+        cleaned_line = _remove_line_comment(line)
+        cleaned_lines.append(cleaned_line)
+
+    return "\n".join(cleaned_lines)
+
+
+def _remove_line_comment(line):
+    # type: (str) -> str
+    """
+    移除单行中的注释部分。
+
+    Args:
+        line (str): 要处理的行
+
+    Returns:
+        str: 移除注释后的行
+    """
+    # 检查是否是整行注释
+    stripped = line.lstrip()
+    if stripped.startswith("#") or stripped.startswith("//"):
+        return ""
+
+    # 查找行内注释,需要考虑字符串内容
+    in_string = False
+    quote_char = None
+    i = 0
+
+    while i < len(line):
+        char = line[i]
+
+        # 处理字符串内的转义序列
+        if in_string and char == "\\" and i + 1 < len(line):
+            i += 2  # 跳过转义字符
+            continue
+
+        # 处理引号字符
+        if char in ('"', "'"):
+            if not in_string:
+                in_string = True
+                quote_char = char
+            elif char == quote_char:
+                in_string = False
+                quote_char = None
+
+        # 在字符串外检查注释标记
+        elif not in_string:
+            if char == "#":
+                return line[:i].rstrip()
+            elif char == "/" and i + 1 < len(line) and line[i + 1] == "/":
+                return line[:i].rstrip()
+
+        i += 1
+
+    return line

+ 5 - 5
tests/test_cache.py

@@ -554,7 +554,7 @@ class TestCache(unittest.TestCase):
         # Clean up
         cache.close()
 
-    @patch('ddns.cache.time')
+    @patch("ddns.cache.time")
     def test_cache_new_outdated_cache(self, mock_time):
         """Test Cache.new with outdated cache file (>72 hours old)"""
         import logging
@@ -575,7 +575,7 @@ class TestCache(unittest.TestCase):
         # Mock the file modification time to be 73 hours ago
         old_mtime = current_time - (73 * 3600)  # 73 hours ago
 
-        with patch('ddns.cache.stat') as mock_stat:
+        with patch("ddns.cache.stat") as mock_stat:
             mock_stat.return_value.st_mtime = old_mtime
             cache = Cache.new(self.cache_file, "test_hash", logger)
 
@@ -608,7 +608,7 @@ class TestCache(unittest.TestCase):
         # Clean up
         cache.close()
 
-    @patch('ddns.cache.time')
+    @patch("ddns.cache.time")
     def test_cache_new_valid_cache(self, mock_time):
         """Test Cache.new with valid cache file with data"""
         import logging
@@ -619,7 +619,7 @@ class TestCache(unittest.TestCase):
         # Create a cache file with test data
         test_data = {
             "domain1.com": {"ip": "1.2.3.4", "timestamp": 1234567890},
-            "domain2.com": {"ip": "5.6.7.8", "timestamp": 1234567891}
+            "domain2.com": {"ip": "5.6.7.8", "timestamp": 1234567891},
         }
         with open(self.cache_file, "w") as f:
             json.dump(test_data, f)
@@ -631,7 +631,7 @@ class TestCache(unittest.TestCase):
         # Mock file modification time to be recent (within 72 hours)
         recent_mtime = current_time - (24 * 3600)  # 24 hours ago
 
-        with patch('ddns.cache.stat') as mock_stat:
+        with patch("ddns.cache.stat") as mock_stat:
             mock_stat.return_value.st_mtime = recent_mtime
             cache = Cache.new(self.cache_file, "test_hash", logger)
 

+ 46 - 0
tests/test_config_file.py

@@ -452,6 +452,52 @@ class TestConfigFile(unittest.TestCase):
         self.assertEqual(loaded_config["symbols"], "αβγδε")
         self.assertEqual(loaded_config["emoji"], "🌍🔧⚡")
 
+    def test_load_config_json_with_hash_comments(self):
+        """测试加载带有 # 注释的JSON配置文件"""
+        json_with_comments = """{
+    # Configuration for DDNS
+    "dns": "cloudflare",  # DNS provider
+    "id": "[email protected]",
+    "token": "secret123",  # API token
+    "ttl": 300
+    # End of config
+}"""
+        file_path = self.create_test_file("test_hash_comments.json", json_with_comments)
+
+        config = load_config(file_path)
+
+        expected = {"dns": "cloudflare", "id": "[email protected]", "token": "secret123", "ttl": 300}
+        self.assertEqual(config, expected)
+
+    def test_load_config_json_with_double_slash_comments(self):
+        """测试加载带有 // 注释的JSON配置文件"""
+        json_with_comments = """{
+    // Configuration for DDNS
+    "$schema": "https://ddns.newfuture.cc/schema/v4.0.json", // Schema validation
+    "debug": false,  // false=disable, true=enable
+    "dns": "dnspod_com",  // DNS provider
+    "id": "1008666",
+    "token": "ae86$cbbcctv666666666666666",  // API Token
+    "ipv4": ["test.lorzl.ml"],  // IPv4 domains
+    "ipv6": ["test.lorzl.ml"],  // IPv6 domains
+    "proxy": null  // Proxy settings
+}"""
+        file_path = self.create_test_file("test_double_slash_comments.json", json_with_comments)
+
+        config = load_config(file_path)
+
+        expected = {
+            "$schema": "https://ddns.newfuture.cc/schema/v4.0.json",
+            "debug": False,
+            "dns": "dnspod_com",
+            "id": "1008666",
+            "token": "ae86$cbbcctv666666666666666",
+            "ipv4": ["test.lorzl.ml"],
+            "ipv6": ["test.lorzl.ml"],
+            "proxy": None,
+        }
+        self.assertEqual(config, expected)
+
     def test_save_config_pretty_format(self):
         """Test that saved JSON is properly formatted"""
         config_data = {"dns": "cloudflare", "log_level": "DEBUG", "log_file": "/var/log/ddns.log"}

+ 248 - 0
tests/test_util_comment.py

@@ -0,0 +1,248 @@
+# coding=utf-8
+"""
+Unit tests for ddns.util.comment module
+@author: GitHub Copilot
+"""
+from __future__ import unicode_literals
+from __init__ import unittest
+from ddns.util.comment import remove_comment
+
+
+class TestRemoveComment(unittest.TestCase):
+    """Test cases for comment removal functionality"""
+
+    def test_remove_comment_empty_string(self):
+        """测试空字符串"""
+        result = remove_comment("")
+        self.assertEqual(result, "")
+
+    def test_remove_comment_no_comments(self):
+        """测试没有注释的内容"""
+        content = '{"key": "value", "number": 123}'
+        result = remove_comment(content)
+        self.assertEqual(result, content)
+
+    def test_remove_comment_hash_full_line(self):
+        """测试整行 # 注释"""
+        content = '# This is a comment\n{"key": "value"}'
+        expected = '\n{"key": "value"}'
+        result = remove_comment(content)
+        self.assertEqual(result, expected)
+
+    def test_remove_comment_double_slash_full_line(self):
+        """测试整行 // 注释"""
+        content = '// This is a comment\n{"key": "value"}'
+        expected = '\n{"key": "value"}'
+        result = remove_comment(content)
+        self.assertEqual(result, expected)
+
+    def test_remove_comment_hash_with_leading_whitespace(self):
+        """测试带前导空白的 # 注释"""
+        content = '   # This is a comment\n{"key": "value"}'
+        expected = '\n{"key": "value"}'
+        result = remove_comment(content)
+        self.assertEqual(result, expected)
+
+    def test_remove_comment_double_slash_with_leading_whitespace(self):
+        """测试带前导空白的 // 注释"""
+        content = '  // This is a comment\n{"key": "value"}'
+        expected = '\n{"key": "value"}'
+        result = remove_comment(content)
+        self.assertEqual(result, expected)
+
+    def test_remove_comment_hash_end_of_line(self):
+        """测试行尾 # 注释"""
+        content = '{"key": "value"} # this is a comment'
+        expected = '{"key": "value"}'
+        result = remove_comment(content)
+        self.assertEqual(result, expected)
+
+    def test_remove_comment_double_slash_end_of_line(self):
+        """测试行尾 // 注释"""
+        content = '{"key": "value"} // this is a comment'
+        expected = '{"key": "value"}'
+        result = remove_comment(content)
+        self.assertEqual(result, expected)
+
+    def test_remove_comment_hash_in_string_should_not_remove(self):
+        """测试字符串内的 # 不应该被移除"""
+        content = '{"url": "http://example.com#anchor"}'
+        result = remove_comment(content)
+        self.assertEqual(result, content)
+
+    def test_remove_comment_double_slash_in_string_should_not_remove(self):
+        """测试字符串内的 // 不应该被移除"""
+        content = '{"url": "http://example.com/path"}'
+        result = remove_comment(content)
+        self.assertEqual(result, content)
+
+    def test_remove_comment_single_quoted_string_with_hash(self):
+        """测试单引号字符串内的 # 不应该被移除"""
+        content = "{'url': 'http://example.com#anchor'}"
+        result = remove_comment(content)
+        self.assertEqual(result, content)
+
+    def test_remove_comment_single_quoted_string_with_double_slash(self):
+        """测试单引号字符串内的 // 不应该被移除"""
+        content = "{'url': 'http://example.com/path'}"
+        result = remove_comment(content)
+        self.assertEqual(result, content)
+
+    def test_remove_comment_escaped_quotes_in_string(self):
+        """测试字符串内转义引号的处理"""
+        content = '{"message": "He said \\"Hello#World\\""} # comment'
+        expected = '{"message": "He said \\"Hello#World\\""}'
+        result = remove_comment(content)
+        self.assertEqual(result, expected)
+
+    def test_remove_comment_complex_json_with_comments(self):
+        """测试复杂JSON配置与多种注释"""
+        content = """{
+    // Configuration file for DDNS
+    "$schema": "https://ddns.newfuture.cc/schema/v4.0.json", // Schema validation
+    "debug": false,  # false=disable, true=enable
+    "dns": "dnspod_com",  // DNS provider
+    "id": "1008666",      # ID or Email
+    "token": "ae86$cbbcctv666666666666666",  // API Token or Key
+    "ipv4": ["test.lorzl.ml"],  # IPv4 domains to update
+    "ipv6": ["test.lorzl.ml"],  // IPv6 domains to update
+    "index4": "public",     # IPv4 update method
+    "index6": "url:https://iptest.com",  # IPv6 update method
+    "proxy": null  // Proxy settings
+}"""
+        expected = """{
+
+    "$schema": "https://ddns.newfuture.cc/schema/v4.0.json",
+    "debug": false,
+    "dns": "dnspod_com",
+    "id": "1008666",
+    "token": "ae86$cbbcctv666666666666666",
+    "ipv4": ["test.lorzl.ml"],
+    "ipv6": ["test.lorzl.ml"],
+    "index4": "public",
+    "index6": "url:https://iptest.com",
+    "proxy": null
+}"""
+        result = remove_comment(content)
+        self.assertEqual(result, expected)
+
+    def test_remove_comment_mixed_comment_styles(self):
+        """测试混合注释风格"""
+        content = """// Header comment
+{
+    # This is a hash comment
+    "key1": "value1", // End of line comment
+    "key2": "value2"  # Another end of line comment
+}
+# Footer comment"""
+        expected = """
+{
+
+    "key1": "value1",
+    "key2": "value2"
+}
+"""
+        result = remove_comment(content)
+        self.assertEqual(result, expected)
+
+    def test_remove_comment_comments_with_special_chars(self):
+        """测试包含特殊字符的注释"""
+        content = """// Comment with 中文字符 and émojis 🚀
+{
+    "test": "value" # Comment with symbols !@#$%^&*()
+}"""
+        expected = """
+{
+    "test": "value"
+}"""
+        result = remove_comment(content)
+        self.assertEqual(result, expected)
+
+    def test_remove_comment_preserve_empty_lines(self):
+        """测试保留空行"""
+        content = """// Comment
+
+{
+    "key": "value"
+}
+
+// Another comment"""
+        expected = """
+
+{
+    "key": "value"
+}
+
+"""
+        result = remove_comment(content)
+        self.assertEqual(result, expected)
+
+    def test_remove_comment_url_with_hash_and_comment(self):
+        """测试URL中包含#,行尾有注释的情况"""
+        content = '{"url": "https://example.com#section"} # This is a comment'
+        expected = '{"url": "https://example.com#section"}'
+        result = remove_comment(content)
+        self.assertEqual(result, expected)
+
+    def test_remove_comment_json_array_with_comments(self):
+        """测试JSON数组与注释"""
+        content = """[
+    // First item
+    "item1", # Comment 1
+    "item2", // Comment 2
+    "item3"  # Last item
+]"""
+        expected = """[
+
+    "item1",
+    "item2",
+    "item3"
+]"""
+        result = remove_comment(content)
+        self.assertEqual(result, expected)
+
+    def test_remove_comment_nested_quotes(self):
+        """测试嵌套引号的处理"""
+        content = """{"message": "She said: \\"Don't use // or # here\\""} // comment"""
+        expected = """{"message": "She said: \\"Don't use // or # here\\""} """
+        result = remove_comment(content)
+        # Note: we expect a trailing space where the comment was removed
+        self.assertEqual(result, expected.rstrip())
+
+    def test_remove_comment_multiple_slashes(self):
+        """测试多个斜杠的情况"""
+        content = '{"path": "C:\\\\Program Files\\\\App"} // Windows path'
+        expected = '{"path": "C:\\\\Program Files\\\\App"}'
+        result = remove_comment(content)
+        self.assertEqual(result, expected)
+
+    def test_remove_comment_hash_after_double_slash_comment(self):
+        """测试 // 注释中包含 # 的情况"""
+        content = '{"key": "value"} // Comment with # symbol'
+        expected = '{"key": "value"}'
+        result = remove_comment(content)
+        self.assertEqual(result, expected)
+
+    def test_remove_comment_single_line_various_formats(self):
+        """测试单行多种格式"""
+        test_cases = [
+            ('{"key": "value"}', '{"key": "value"}'),  # No comment
+            ("# Full line comment", ""),  # Full line hash
+            ("// Full line comment", ""),  # Full line double slash
+            ("   # Indented comment", ""),  # Indented hash
+            ("   // Indented comment", ""),  # Indented double slash
+            ('{"a": "b"} # End comment', '{"a": "b"}'),  # End hash
+            ('{"a": "b"} // End comment', '{"a": "b"}'),  # End double slash
+        ]
+
+        for i, (input_content, expected) in enumerate(test_cases):
+            result = remove_comment(input_content)
+            self.assertEqual(
+                result,
+                expected,
+                "Failed for test case %d: %r -> expected %r, got %r" % (i, input_content, expected, result),
+            )
+
+
+if __name__ == "__main__":
+    unittest.main()