Skip to content

Commit fc79937

Browse files
authored
Adding Support for Json Comments (#42320)
* Adding Support for Json Comments * adding tests * fixing formatting * Update _json.py * minor improvement * Update CHANGELOG.md * fixing test * Fixing Error catching * fixed // formatting
1 parent 9ba9a82 commit fc79937

File tree

5 files changed

+261
-4
lines changed

5 files changed

+261
-4
lines changed

sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Features Added
66

77
- Added `tag_filters` in `SettingSelector` to filter settings by tags.
8+
- Added support for JSON comments in the `load` method, when a configuration setting has the json content type.
89

910
### Breaking Changes
1011

sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
)
4141
from ._client_manager import ConfigurationClientManager
4242
from ._user_agent import USER_AGENT
43+
from ._json import remove_json_comments
4344

4445
if TYPE_CHECKING:
4546
from azure.core.credentials import TokenCredential
@@ -504,8 +505,12 @@ def _process_key_value(self, config):
504505
self._uses_aicc_configuration = True
505506
return json.loads(config.value)
506507
except json.JSONDecodeError:
507-
# If the value is not a valid JSON, treat it like regular string value
508-
return config.value
508+
try:
509+
# If the value is not a valid JSON, check if it has comments and remove them
510+
return json.loads(remove_json_comments(config.value))
511+
except (json.JSONDecodeError, ValueError):
512+
# If the value is not a valid JSON, treat it like regular string value
513+
return config.value
509514
return config.value
510515

511516
def __eq__(self, other: Any) -> bool:
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
7+
ESCAPE_CHAR = "\\"
8+
DOUBLE_FORWARD_SLASH_COMMENT = "//"
9+
MULTI_LINE_COMMENT = "/*"
10+
END_MULTI_LINE_COMMENT = "*/"
11+
DOUBLE_QUOTE = '"'
12+
NEW_LINE = "\n"
13+
14+
15+
def _find_string_end(text: str, index: int) -> int:
16+
"""
17+
Helper function that finds the end of a string literal.
18+
19+
:param text: The input text to process.
20+
:type text: str
21+
:param index: The current index in the text, should be the position right after the opening quote.
22+
:type index: int
23+
:raises ValueError: If there is an unterminated string literal.
24+
:return: The index of the closing quote.
25+
:rtype: int
26+
"""
27+
while index < len(text):
28+
current_char = text[index]
29+
if current_char == DOUBLE_QUOTE:
30+
# Check for escaped quote
31+
esc_count = 0
32+
j = index - 1
33+
while j >= 0 and text[j] == ESCAPE_CHAR:
34+
esc_count += 1
35+
j -= 1
36+
if esc_count % 2 == 0:
37+
# Found end of string
38+
index += 1
39+
return index
40+
index += 1
41+
raise ValueError("Unterminated string literal")
42+
43+
44+
def remove_json_comments(text: str) -> str:
45+
"""
46+
Removes comments from a JSON file. Supports //, and /* ... */ comments.
47+
Returns as string.
48+
49+
:param text: The input JSON string with comments.
50+
:type text: str
51+
:raises ValueError: If there is an unterminated string literal.
52+
:return: A string with comments removed.
53+
:rtype: str
54+
"""
55+
result = []
56+
index = 0
57+
length = len(text)
58+
while index < length:
59+
current_char = text[index]
60+
if current_char == DOUBLE_QUOTE:
61+
result.append(current_char)
62+
index += 1
63+
string_end = _find_string_end(text, index)
64+
result.append(text[index:string_end])
65+
index = string_end
66+
elif text[index : index + 2] == DOUBLE_FORWARD_SLASH_COMMENT:
67+
# Skip to end of line or end of file
68+
index += 2
69+
while index < length and text[index] != NEW_LINE:
70+
index += 1
71+
# If we found a newline, move past it
72+
if index < length and text[index] == NEW_LINE:
73+
result.append(NEW_LINE)
74+
index += 1
75+
elif text[index : index + 2] == MULTI_LINE_COMMENT:
76+
# Skip to end of block comment
77+
index += 2
78+
79+
# Search for the end of the comment
80+
found_end = False
81+
while index < length - 1:
82+
if index + 1 < length and text[index : index + 2] == END_MULTI_LINE_COMMENT:
83+
found_end = True
84+
index += 2 # Skip past the end marker
85+
break
86+
index += 1
87+
88+
# If we reached the end without finding the comment closer, raise an error
89+
if not found_end:
90+
raise ValueError("Unterminated multi-line comment")
91+
else:
92+
result.append(current_char)
93+
index += 1
94+
return "".join(result)

sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
)
4545
from ._async_client_manager import AsyncConfigurationClientManager
4646
from .._user_agent import USER_AGENT
47+
from .._json import remove_json_comments
4748

4849
if TYPE_CHECKING:
4950
from azure.core.credentials_async import AsyncTokenCredential
@@ -523,8 +524,12 @@ async def _process_key_value(self, config):
523524
self._uses_aicc_configuration = True
524525
return json.loads(config.value)
525526
except json.JSONDecodeError:
526-
# If the value is not a valid JSON, treat it like regular string value
527-
return config.value
527+
try:
528+
# If the value is not a valid JSON, check if it has comments and remove them
529+
return json.loads(remove_json_comments(config.value))
530+
except (json.JSONDecodeError, ValueError):
531+
# If the value is not a valid JSON, treat it like regular string value
532+
return config.value
528533
return config.value
529534

530535
def __eq__(self, other: Any) -> bool:
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
7+
import unittest
8+
import json
9+
from json import JSONDecodeError
10+
from azure.appconfiguration.provider._json import (
11+
remove_json_comments,
12+
_find_string_end,
13+
)
14+
15+
16+
class TestJsonUtils(unittest.TestCase):
17+
def test_remove_json_comments_no_comments(self):
18+
# Test a simple JSON with no comments
19+
input_json = '{"key": "value", "number": 123}'
20+
result = remove_json_comments(input_json)
21+
# Verify the result is valid JSON
22+
parsed = json.loads(result)
23+
self.assertEqual(parsed["key"], "value")
24+
self.assertEqual(parsed["number"], 123)
25+
26+
def test_remove_json_comments_single_line(self):
27+
# Test removing single line comments
28+
input_json = """{
29+
"key": "value" // this is a comment
30+
}"""
31+
result = remove_json_comments(input_json)
32+
# Verify the result is valid JSON
33+
parsed = json.loads(result)
34+
self.assertEqual(parsed["key"], "value")
35+
36+
def test_remove_json_comments_single_line_no_newline(self):
37+
# Test removing single line comments without a newline at the end
38+
input_json = '{ "key": "value" } // this is a comment'
39+
result = remove_json_comments(input_json)
40+
# Verify the result is valid JSON
41+
parsed = json.loads(result)
42+
self.assertEqual(parsed["key"], "value")
43+
44+
def test_remove_json_comments_multi_line(self):
45+
# Test removing multi-line comments
46+
input_json = """{/* This is a
47+
multi-line
48+
comment */
49+
"key": "value"
50+
}"""
51+
result = remove_json_comments(input_json)
52+
# Verify the result is valid JSON
53+
parsed = json.loads(result)
54+
self.assertEqual(parsed["key"], "value")
55+
56+
def test_remove_json_comments_mixed(self):
57+
# Test removing both single-line and multi-line comments
58+
input_json = """{
59+
"key1": "value1", // single line comment
60+
/* multi-line comment
61+
spanning multiple lines */
62+
"key2": "value2"
63+
}"""
64+
result = remove_json_comments(input_json)
65+
# Verify the result is valid JSON
66+
parsed = json.loads(result)
67+
self.assertEqual(parsed["key1"], "value1")
68+
self.assertEqual(parsed["key2"], "value2")
69+
70+
def test_remove_json_comments_inside_string(self):
71+
# Test that comments within string literals are not removed
72+
input_json = '{"key": "value with // not a comment"}'
73+
result = remove_json_comments(input_json)
74+
# Verify the result is valid JSON
75+
parsed = json.loads(result)
76+
self.assertEqual(parsed["key"], "value with // not a comment")
77+
78+
input_json = '{"key": "value with /* not a comment */"}'
79+
result = remove_json_comments(input_json)
80+
# Verify the result is valid JSON
81+
parsed = json.loads(result)
82+
self.assertEqual(parsed["key"], "value with /* not a comment */")
83+
84+
def test_remove_json_comments_nested_comments(self):
85+
# Test with nested comments (which aren't really supported in JSON)
86+
input_json = """{
87+
/* outer comment /* inner comment */ */
88+
"key": "value"
89+
}"""
90+
result = remove_json_comments(input_json)
91+
# Verify the json is valid after comment removal, we don't support nested comments
92+
with self.assertRaises(JSONDecodeError):
93+
json.loads(result)
94+
95+
def test_remove_json_comments_escaped_quotes(self):
96+
# Test with escaped quotes
97+
input_json = '{"key": "value with \\" escaped quote // not a comment"}'
98+
result = remove_json_comments(input_json)
99+
# Verify the result is valid JSON
100+
parsed = json.loads(result)
101+
self.assertEqual(parsed["key"], 'value with " escaped quote // not a comment')
102+
103+
def test_find_string_end_basic(self):
104+
# Test basic string end finding
105+
text = '"string" more'
106+
index = _find_string_end(text, 1)
107+
self.assertEqual(index, 8)
108+
self.assertEqual(text[1:index], 'string"')
109+
110+
def test_find_string_end_escaped_quote(self):
111+
# Test with escaped quotes
112+
text = '"escaped \\" quote" end'
113+
index = _find_string_end(text, 1)
114+
self.assertEqual(index, 18)
115+
self.assertEqual(text[1:index], 'escaped \\" quote"')
116+
117+
def test_find_string_end_unterminated(self):
118+
# Test with unterminated string
119+
text = '"unterminated string'
120+
with self.assertRaises(ValueError):
121+
_find_string_end(text, 1)
122+
123+
def test_find_string_end_multiple_escapes(self):
124+
# Test with multiple escape characters
125+
text = '"multiple \\\\" end'
126+
index = _find_string_end(text, 1)
127+
self.assertEqual(index, 13)
128+
self.assertEqual(text[1:index], 'multiple \\\\"')
129+
130+
def test_remove_json_comments_unterminated_string(self):
131+
# Test with unterminated string
132+
input_json = '{ "key": "unterminated string }'
133+
with self.assertRaises(ValueError):
134+
remove_json_comments(input_json)
135+
136+
def test_remove_json_comments_unterminated_multiline(self):
137+
# Test with unterminated multi-line comment
138+
# This should raise a ValueError
139+
input_json = """{
140+
"key": "value"
141+
/* unterminated comment
142+
}"""
143+
with self.assertRaises(ValueError):
144+
remove_json_comments(input_json)
145+
146+
# Another test case for unterminated multi-line comment
147+
input_json2 = """{
148+
"key1": "value1",
149+
"key2": "value2" /* unterminated comment
150+
}"""
151+
with self.assertRaises(ValueError):
152+
remove_json_comments(input_json2)

0 commit comments

Comments
 (0)