Coverage for src / jsonclark / parser.py: 81%
140 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-16 12:11 +0300
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-16 12:11 +0300
1"""JSON parser with support for comments using Lark."""
3from dataclasses import dataclass, field
4from typing import Any, Optional, IO
6from lark import Lark, Transformer, v_args
9# Lark grammar for JSON with comments
10JSON_WITH_COMMENTS_GRAMMAR = r"""
11 ?value: object
12 | array
13 | string
14 | number
15 | "true" -> true
16 | "false" -> false
17 | "null" -> null
19 array : "[" [value ("," value)*] "]"
20 object : "{" [pair ("," pair)*] "}"
21 pair : string ":" value
23 string : ESCAPED_STRING
25 number : SIGNED_NUMBER
27 %import common.ESCAPED_STRING
28 %import common.SIGNED_NUMBER
29 %import common.WS
30 %import common.C_COMMENT
31 %import common.CPP_COMMENT
33 %ignore WS
34 %ignore C_COMMENT
35 %ignore CPP_COMMENT
36"""
39class JSONList(list):
40 """A list subclass that preserves comment metadata without showing it in repr."""
42 def __init__(self, *args, **kwargs):
43 super().__init__(*args, **kwargs)
44 self._comments_before: list[str] = []
45 self._comments_after: list[str] = []
46 self._element_comments: dict[int, tuple[list[str], list[str]]] = {}
48 def __repr__(self) -> str:
49 """Return clean repr that mimics standard list."""
50 return list.__repr__(self)
52 def __eq__(self, other: Any) -> bool:
53 """Compare as standard list."""
54 if isinstance(other, list):
55 return list.__eq__(self, other)
56 return False
58 def set_comments_before(self, comments: list[str]) -> None:
59 """Set comments that appear before this list."""
60 self._comments_before = comments
62 def set_comments_after(self, comments: list[str]) -> None:
63 """Set comments that appear after this list."""
64 self._comments_after = comments
66 def set_element_comments(
67 self,
68 index: int,
69 before: Optional[list[str]] = None,
70 after: Optional[list[str]] = None,
71 ) -> None:
72 """Set comments for a specific element."""
73 if before is None:
74 before = []
75 if after is None:
76 after = []
77 self._element_comments[index] = (before, after)
79 def get_comments(self) -> dict[str, Any]:
80 """Get all comment metadata for serialization."""
81 return {
82 "before": self._comments_before,
83 "after": self._comments_after,
84 "elements": self._element_comments,
85 }
88class JSONDict(dict):
89 """A dict subclass that preserves comment metadata without showing it in repr."""
91 def __init__(self, *args, **kwargs):
92 super().__init__(*args, **kwargs)
93 self._comments_before: list[str] = []
94 self._comments_after: list[str] = []
95 self._key_comments: dict[str, tuple[list[str], list[str]]] = {}
97 def __repr__(self) -> str:
98 """Return clean repr that mimics standard dict."""
99 return dict.__repr__(self)
101 def __eq__(self, other: Any) -> bool:
102 """Compare as standard dict."""
103 if isinstance(other, dict):
104 return dict.__eq__(self, other)
105 return False
107 def set_comments_before(self, comments: list[str]) -> None:
108 """Set comments that appear before this dict."""
109 self._comments_before = comments
111 def set_comments_after(self, comments: list[str]) -> None:
112 """Set comments that appear after this dict."""
113 self._comments_after = comments
115 def set_key_comments(
116 self,
117 key: str,
118 before: Optional[list[str]] = None,
119 after: Optional[list[str]] = None,
120 ) -> None:
121 """Set comments for a specific key."""
122 if before is None:
123 before = []
124 if after is None:
125 after = []
126 self._key_comments[key] = (before, after)
128 def get_comments(self) -> dict[str, Any]:
129 """Get all comment metadata for serialization."""
130 return {
131 "before": self._comments_before,
132 "after": self._comments_after,
133 "keys": self._key_comments,
134 }
137@dataclass
138class JSONValue:
139 """Represents a JSON value with optional associated comments."""
141 value: Any
142 comments_before: list[str] = field(default_factory=list)
143 comments_after: list[str] = field(default_factory=list)
145 def __repr__(self) -> str:
146 return f"JSONValue(value={self.value!r}, comments_before={self.comments_before}, comments_after={self.comments_after})"
149class JSONTransformer(Transformer):
150 """Transformer that converts Lark tree to Python objects."""
152 @v_args(inline=True)
153 def string(self, s: Any) -> str:
154 return s.value[1:-1].encode().decode("unicode_escape")
156 @v_args(inline=True)
157 def number(self, n: Any) -> float | int:
158 val = n.value
159 return int(val) if "." not in val and "e" not in val.lower() else float(val)
161 def array(self, items: list) -> JSONList:
162 filtered = [item for item in items if item is not None]
163 result = JSONList(filtered)
164 return result
166 def pair(self, items: list) -> tuple:
167 return tuple(items)
169 def object(self, items: list) -> JSONDict:
170 filtered = [item for item in items if item is not None]
171 result = JSONDict(dict(filtered))
172 return result
174 def true(self, _=None) -> bool:
175 return True
177 def false(self, _=None) -> bool:
178 return False
180 def null(self, _=None) -> None:
181 return None
184class JSONDecoder:
185 """Decoder for JSON with comments, similar to json.JSONDecoder."""
187 def __init__(self) -> None:
188 self.parser = Lark(
189 JSON_WITH_COMMENTS_GRAMMAR,
190 start="value",
191 parser="lalr",
192 transformer=JSONTransformer(),
193 )
194 self._extracted_comments: list[str] = []
196 def decode(self, s: str) -> Any:
197 """Decode a JSON string, ignoring comments.
199 Args:
200 s: JSON string possibly containing C-style comments
202 Returns:
203 Parsed Python object (JSONDict/JSONList/str/number/bool/None)
205 Raises:
206 lark.exceptions.LarkError: If JSON is invalid
207 """
208 self._extracted_comments = self._extract_comments(s)
209 return self.parser.parse(s)
211 def decode_with_comments(self, s: str) -> tuple[Any, list[str]]:
212 """Decode JSON and return both value and extracted comments.
214 Args:
215 s: JSON string possibly containing C-style comments
217 Returns:
218 Tuple of (parsed_value, comments_list)
219 """
220 comments = self._extract_comments(s)
221 value = self.decode(s)
222 return value, comments
224 # Backward compatibility methods
225 def parse(self, text: str) -> Any:
226 """Backward compatibility method. Use decode() instead."""
227 return self.decode(text)
229 def parse_with_comments(self, text: str) -> dict[str, Any]:
230 """Backward compatibility method. Use decode_with_comments() instead."""
231 value, comments = self.decode_with_comments(text)
232 return {"value": value, "comments": comments}
234 @staticmethod
235 def _extract_comments(text: str) -> list[str]:
236 """Extract all comments from JSON text."""
237 comments = []
238 i = 0
239 while i < len(text):
240 if i < len(text) - 1 and text[i : i + 2] == "//":
241 end = text.find("\n", i)
242 if end == -1:
243 end = len(text)
244 comment = text[i + 2 : end].strip()
245 if comment:
246 comments.append(comment)
247 i = end
248 elif i < len(text) - 1 and text[i : i + 2] == "/*":
249 end = text.find("*/", i + 2)
250 if end != -1:
251 comment = text[i + 2 : end].strip()
252 if comment:
253 comments.append(comment)
254 i = end + 2
255 else:
256 i += 1
257 else:
258 i += 1
259 return comments
262# Module-level decoder instance (like json module)
263_default_decoder = JSONDecoder()
266def loads(s: str) -> Any:
267 """Deserialize a JSON string with comments to a Python object.
269 Similar to json.loads(), but supports C-style comments (// and /* */).
271 Args:
272 s: JSON string possibly containing comments
274 Returns:
275 Parsed Python object (JSONDict/JSONList/str/number/bool/None)
277 Raises:
278 lark.exceptions.LarkError: If JSON is invalid
280 Examples:
281 >>> data = loads('{\"name\": \"Alice\"} // comment')
282 >>> data['name']
283 'Alice'
284 """
285 return _default_decoder.decode(s)
288def load(fp: IO[str]) -> Any:
289 """Deserialize a JSON file with comments to a Python object.
291 Similar to json.load(), but supports C-style comments (// and /* */).
293 Args:
294 fp: File-like object containing JSON with possible comments
296 Returns:
297 Parsed Python object (JSONDict/JSONList/str/number/bool/None)
299 Raises:
300 lark.exceptions.LarkError: If JSON is invalid
302 Examples:
303 >>> with open('config.json') as f:
304 ... config = load(f)
305 """
306 return loads(fp.read())
309def loads_comments(s: str) -> tuple[Any, list[str]]:
310 """Deserialize JSON with comments, returning value and extracted comments.
312 Args:
313 s: JSON string possibly containing comments
315 Returns:
316 Tuple of (parsed_value, comments_list)
318 Examples:
319 >>> data, comments = loads_comments('// Config\\n{\"x\": 1}')
320 >>> data
321 {'x': 1}
322 >>> comments
323 ['Config']
324 """
325 return _default_decoder.decode_with_comments(s)
328def load_comments(fp: IO[str]) -> tuple[Any, list[str]]:
329 """Deserialize JSON file with comments, returning value and extracted comments.
331 Args:
332 fp: File-like object containing JSON with possible comments
334 Returns:
335 Tuple of (parsed_value, comments_list)
337 Examples:
338 >>> with open('config.json') as f:
339 ... config, comments = load_comments(f)
340 """
341 return loads_comments(fp.read())
344def extract_comments(text: str) -> list[str]:
345 """Extract all comments from JSON text.
347 Args:
348 text: JSON string possibly containing comments
350 Returns:
351 List of comment strings (without the comment delimiters)
353 Examples:
354 >>> extract_comments('// comment\\n{}')
355 ['comment']
356 """
357 return JSONDecoder._extract_comments(text)
360# Convenience aliases for backward compatibility
361load_json_with_comments = loads
362load_json_preserving_comments = loads_comments
363JSONWithCommentsParser = JSONDecoder
366# Backward compatibility wrapper for old API
367def _old_style_load_json_preserving_comments(s: str) -> dict[str, Any]:
368 """Old API wrapper that returns dict with 'value' and 'comments' keys."""
369 value, comments = loads_comments(s)
370 return {"value": value, "comments": comments}
373# Override the alias to use the wrapper
374load_json_preserving_comments = _old_style_load_json_preserving_comments