Improve ZON parser tuple handling with configurable empty tuple parsing
- Add `empty_tuple_as_dict` parameter to control parsing of empty tuples - Enhance tuple and object parsing logic to handle nested and complex structures - Update test cases to cover various tuple and empty tuple scenarios - Modify `parse_zon_file` and `zon_to_json` functions to support new parsing option
This commit is contained in:
@ -13,11 +13,17 @@ class TestZonParser:
|
|||||||
"""Test cases for the ZonParser class."""
|
"""Test cases for the ZonParser class."""
|
||||||
|
|
||||||
def test_parse_empty_object(self):
|
def test_parse_empty_object(self):
|
||||||
"""Test parsing an empty object."""
|
"""Test parsing an empty object with empty_tuple_as_dict=True."""
|
||||||
parser = ZonParser(".{}")
|
parser = ZonParser(".{}", empty_tuple_as_dict=True)
|
||||||
result = parser.parse()
|
result = parser.parse()
|
||||||
assert result == {}
|
assert result == {}
|
||||||
|
|
||||||
|
def test_parse_empty_tuple_as_list(self):
|
||||||
|
"""Test parsing an empty tuple as list with empty_tuple_as_dict=False."""
|
||||||
|
parser = ZonParser(".{}", empty_tuple_as_dict=False)
|
||||||
|
result = parser.parse()
|
||||||
|
assert result == []
|
||||||
|
|
||||||
def test_parse_simple_object(self):
|
def test_parse_simple_object(self):
|
||||||
"""Test parsing a simple object with string values."""
|
"""Test parsing a simple object with string values."""
|
||||||
parser = ZonParser(
|
parser = ZonParser(
|
||||||
@ -42,16 +48,6 @@ class TestZonParser:
|
|||||||
result = parser.parse()
|
result = parser.parse()
|
||||||
assert result == {"metadata": {"name": "test", "version": "1.0.0"}}
|
assert result == {"metadata": {"name": "test", "version": "1.0.0"}}
|
||||||
|
|
||||||
def test_parse_array(self):
|
|
||||||
"""Test parsing an array."""
|
|
||||||
parser = ZonParser(
|
|
||||||
""".{
|
|
||||||
.tags = .["tag1", "tag2", "tag3"],
|
|
||||||
}"""
|
|
||||||
)
|
|
||||||
result = parser.parse()
|
|
||||||
assert result == {"tags": ["tag1", "tag2", "tag3"]}
|
|
||||||
|
|
||||||
def test_parse_numbers(self):
|
def test_parse_numbers(self):
|
||||||
"""Test parsing different number formats."""
|
"""Test parsing different number formats."""
|
||||||
parser = ZonParser(
|
parser = ZonParser(
|
||||||
@ -169,6 +165,65 @@ class TestZonParser:
|
|||||||
"mixed_tuple": [1, "two", True],
|
"mixed_tuple": [1, "two", True],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def test_parse_nested_tuples(self):
|
||||||
|
"""Test parsing nested tuples."""
|
||||||
|
parser = ZonParser(
|
||||||
|
""".{
|
||||||
|
.nested_tuple = .{ .{1, 2}, .{3, 4} },
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
result = parser.parse()
|
||||||
|
assert result == {"nested_tuple": [[1, 2], [3, 4]]}
|
||||||
|
|
||||||
|
def test_parse_objects_in_tuple(self):
|
||||||
|
"""Test parsing objects within tuples."""
|
||||||
|
parser = ZonParser(
|
||||||
|
""".{
|
||||||
|
.object_in_tuple = .{ .{.x = 1, .y = 2}, .{.x = 3, .y = 4} },
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
result = parser.parse()
|
||||||
|
assert result == {"object_in_tuple": [{"x": 1, "y": 2}, {"x": 3, "y": 4}]}
|
||||||
|
|
||||||
|
def test_parse_complex_nested_structures(self):
|
||||||
|
"""Test parsing complex nested structures."""
|
||||||
|
parser = ZonParser(
|
||||||
|
""".{
|
||||||
|
.metadata = .{
|
||||||
|
.name = "example",
|
||||||
|
.version = "1.0.0",
|
||||||
|
},
|
||||||
|
.simple_tuple = .{1, 2, 3},
|
||||||
|
.nested = .{
|
||||||
|
.tuple_in_object = .{4, 5, 6},
|
||||||
|
.object_in_tuple = .{ .{.x = 1, .y = 2}, .{.x = 3, .y = 4} },
|
||||||
|
},
|
||||||
|
.nested_tuple = .{ .{1, 2}, .{3, 4} },
|
||||||
|
.empty = .{},
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
result = parser.parse()
|
||||||
|
assert result == {
|
||||||
|
"metadata": {"name": "example", "version": "1.0.0"},
|
||||||
|
"simple_tuple": [1, 2, 3],
|
||||||
|
"nested": {
|
||||||
|
"tuple_in_object": [4, 5, 6],
|
||||||
|
"object_in_tuple": [{"x": 1, "y": 2}, {"x": 3, "y": 4}],
|
||||||
|
},
|
||||||
|
"nested_tuple": [[1, 2], [3, 4]],
|
||||||
|
"empty": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_parse_tuple_as_array(self):
|
||||||
|
"""Test parsing a tuple as an array."""
|
||||||
|
parser = ZonParser(
|
||||||
|
""".{
|
||||||
|
.tags = .{"tag1", "tag2", "tag3"},
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
result = parser.parse()
|
||||||
|
assert result == {"tags": ["tag1", "tag2", "tag3"]}
|
||||||
|
|
||||||
|
|
||||||
class TestZonFileParser:
|
class TestZonFileParser:
|
||||||
"""Test cases for the file parsing functions."""
|
"""Test cases for the file parsing functions."""
|
||||||
|
|||||||
@ -14,17 +14,20 @@ class ZonParser:
|
|||||||
A parser for Zig Object Notation (ZON) files.
|
A parser for Zig Object Notation (ZON) files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, content: str):
|
def __init__(self, content: str, empty_tuple_as_dict: bool = False):
|
||||||
"""
|
"""
|
||||||
Initialize the parser with ZON content.
|
Initialize the parser with ZON content.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
content: The ZON content to parse
|
content: The ZON content to parse
|
||||||
|
empty_tuple_as_dict: If True, empty tuples (.{}) will be parsed as empty dictionaries ({})
|
||||||
|
If False, empty tuples will be parsed as empty lists ([])
|
||||||
"""
|
"""
|
||||||
self.content = content
|
self.content = content
|
||||||
self.pos = 0
|
self.pos = 0
|
||||||
self.line = 1
|
self.line = 1
|
||||||
self.col = 1
|
self.col = 1
|
||||||
|
self.empty_tuple_as_dict = empty_tuple_as_dict
|
||||||
|
|
||||||
def parse(self) -> Dict[str, Any]:
|
def parse(self) -> Dict[str, Any]:
|
||||||
"""Parse ZON content and return a Python dictionary."""
|
"""Parse ZON content and return a Python dictionary."""
|
||||||
@ -71,6 +74,7 @@ class ZonParser:
|
|||||||
break
|
break
|
||||||
|
|
||||||
def _parse_value(self) -> Any:
|
def _parse_value(self) -> Any:
|
||||||
|
"""Parse a ZON value."""
|
||||||
self._skip_whitespace_and_comments()
|
self._skip_whitespace_and_comments()
|
||||||
|
|
||||||
char = self._current_char()
|
char = self._current_char()
|
||||||
@ -100,33 +104,59 @@ class ZonParser:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _parse_object(self) -> Union[Dict[str, Any], List[Any]]:
|
def _parse_object(self) -> Union[Dict[str, Any], List[Any]]:
|
||||||
"""Parse a ZON object (anonymous struct) or tuple."""
|
"""Parse a ZON object or tuple."""
|
||||||
# Skip the opening brace
|
# Skip the opening brace
|
||||||
self._next_char()
|
self._next_char()
|
||||||
|
|
||||||
# Check if it's a tuple (values separated by commas without keys)
|
# Look ahead to see if this is a tuple or an object
|
||||||
is_tuple = False
|
pos_before = self.pos
|
||||||
pos_before_check = self.pos
|
line_before = self.line
|
||||||
line_before_check = self.line
|
col_before = self.col
|
||||||
col_before_check = self.col
|
|
||||||
|
|
||||||
# Look ahead to see if this is a tuple
|
|
||||||
self._skip_whitespace_and_comments()
|
self._skip_whitespace_and_comments()
|
||||||
if self._current_char() != ".":
|
|
||||||
# If it doesn't start with a dot for a key, it might be a tuple
|
|
||||||
# or an empty object
|
|
||||||
if self._current_char() != "}":
|
|
||||||
is_tuple = True
|
|
||||||
|
|
||||||
# Reset position after look-ahead
|
# Check if it's empty
|
||||||
self.pos = pos_before_check
|
if self._current_char() == "}":
|
||||||
self.line = line_before_check
|
# Need to determine if it should be an empty object or empty tuple
|
||||||
self.col = col_before_check
|
# Use the configuration option to decide
|
||||||
|
self._next_char() # Skip the closing brace
|
||||||
|
return (
|
||||||
|
{} if self.empty_tuple_as_dict else []
|
||||||
|
) # Empty dict or list based on config
|
||||||
|
|
||||||
|
# Look at the first character to determine if it's a tuple or object
|
||||||
|
is_tuple = True
|
||||||
|
if self._current_char() == ".":
|
||||||
|
# Look ahead one more character
|
||||||
|
self._next_char()
|
||||||
|
# If the next character is an object, it could be a nested tuple
|
||||||
|
if self._current_char() == "{":
|
||||||
|
# This is potentially a nested tuple starting with .{
|
||||||
|
# Go back to the dot and let the normal parsing decide
|
||||||
|
self.pos -= 1
|
||||||
|
elif (
|
||||||
|
self._current_char() == "@"
|
||||||
|
or self._current_char().isalnum()
|
||||||
|
or self._current_char() == "_"
|
||||||
|
):
|
||||||
|
# This looks like a field name, so it's probably an object
|
||||||
|
is_tuple = False
|
||||||
|
else:
|
||||||
|
# Unexpected character after dot, could be a syntax error
|
||||||
|
is_tuple = False
|
||||||
|
|
||||||
|
# Reset position
|
||||||
|
self.pos = pos_before
|
||||||
|
self.line = line_before
|
||||||
|
self.col = col_before
|
||||||
|
|
||||||
if is_tuple:
|
if is_tuple:
|
||||||
return self._parse_tuple()
|
return self._parse_tuple()
|
||||||
|
else:
|
||||||
|
return self._parse_struct()
|
||||||
|
|
||||||
# Regular object parsing
|
def _parse_struct(self) -> Dict[str, Any]:
|
||||||
|
"""Parse a ZON struct/object with key-value pairs."""
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@ -171,10 +201,25 @@ class ZonParser:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _parse_tuple(self) -> List[Any]:
|
def _parse_tuple(self) -> Union[Dict[str, Any], List[Any]]:
|
||||||
"""Parse a tuple in ZON format."""
|
"""
|
||||||
|
Parse a ZON tuple as a list of values or empty dict based on configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[Any] for non-empty tuples, or Dict[str, Any] if empty and empty_tuple_as_dict=True
|
||||||
|
"""
|
||||||
result = []
|
result = []
|
||||||
|
|
||||||
|
# Skip the opening brace (already done in _parse_object)
|
||||||
|
self._skip_whitespace_and_comments()
|
||||||
|
|
||||||
|
# Check for empty tuple
|
||||||
|
if self._current_char() == "}":
|
||||||
|
self._next_char()
|
||||||
|
return (
|
||||||
|
{} if self.empty_tuple_as_dict else []
|
||||||
|
) # Empty dict or list based on config
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
self._skip_whitespace_and_comments()
|
self._skip_whitespace_and_comments()
|
||||||
|
|
||||||
@ -183,17 +228,27 @@ class ZonParser:
|
|||||||
self._next_char()
|
self._next_char()
|
||||||
break
|
break
|
||||||
|
|
||||||
# Check for nested tuple or object with dot prefix
|
# Handle the special case of nested tuple/object with dot prefix
|
||||||
if self._current_char() == ".":
|
if self._current_char() == ".":
|
||||||
|
# Save position before the dot
|
||||||
|
pos_before = self.pos
|
||||||
|
line_before = self.line
|
||||||
|
col_before = self.col
|
||||||
|
|
||||||
self._next_char() # Skip the dot
|
self._next_char() # Skip the dot
|
||||||
|
|
||||||
# Check if it's a nested object or tuple
|
# If we have a nested object/tuple
|
||||||
if self._current_char() == "{":
|
if self._current_char() == "{":
|
||||||
|
# Parse the nested object/tuple
|
||||||
value = self._parse_object()
|
value = self._parse_object()
|
||||||
result.append(value)
|
result.append(value)
|
||||||
else:
|
else:
|
||||||
# It's a field name or a special value
|
# Not a nested tuple/object, reset position and parse normally
|
||||||
self.pos -= 1 # Move back to include the dot
|
self.pos = pos_before
|
||||||
|
self.line = line_before
|
||||||
|
self.col = col_before
|
||||||
|
|
||||||
|
# Parse as normal value
|
||||||
value = self._parse_value()
|
value = self._parse_value()
|
||||||
result.append(value)
|
result.append(value)
|
||||||
else:
|
else:
|
||||||
@ -346,12 +401,14 @@ class ZonParser:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_zon_file(file_path: str) -> Dict[str, Any]:
|
def parse_zon_file(file_path: str, empty_tuple_as_dict: bool = False) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Parse a ZON file and return a Python dictionary.
|
Parse a ZON file and return a Python dictionary.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the ZON file
|
file_path: Path to the ZON file
|
||||||
|
empty_tuple_as_dict: If True, empty tuples (.{}) will be parsed as empty dictionaries ({})
|
||||||
|
If False, empty tuples will be parsed as empty lists ([])
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary representation of the ZON file
|
Dictionary representation of the ZON file
|
||||||
@ -360,23 +417,27 @@ def parse_zon_file(file_path: str) -> Dict[str, Any]:
|
|||||||
with open(file_path, "r") as f:
|
with open(file_path, "r") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
parser = ZonParser(content)
|
parser = ZonParser(content, empty_tuple_as_dict=empty_tuple_as_dict)
|
||||||
result = parser.parse()
|
result = parser.parse()
|
||||||
logger.debug(f"Successfully parsed ZON file")
|
logger.debug(f"Successfully parsed ZON file")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def zon_to_json(zon_content: str, indent: Optional[int] = None) -> str:
|
def zon_to_json(
|
||||||
|
zon_content: str, indent: Optional[int] = None, empty_tuple_as_dict: bool = False
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Convert ZON content to JSON string.
|
Convert ZON content to JSON string.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
zon_content: ZON content as string
|
zon_content: ZON content as string
|
||||||
indent: Number of spaces for indentation (None for compact JSON)
|
indent: Number of spaces for indentation (None for compact JSON)
|
||||||
|
empty_tuple_as_dict: If True, empty tuples (.{}) will be parsed as empty dictionaries ({})
|
||||||
|
If False, empty tuples will be parsed as empty lists ([])
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON string
|
JSON string
|
||||||
"""
|
"""
|
||||||
parser = ZonParser(zon_content)
|
parser = ZonParser(zon_content, empty_tuple_as_dict=empty_tuple_as_dict)
|
||||||
result = parser.parse()
|
result = parser.parse()
|
||||||
return json.dumps(result, indent=indent)
|
return json.dumps(result, indent=indent)
|
||||||
|
|||||||
Reference in New Issue
Block a user