1+ import logging
12from typing import NamedTuple
23
34from killerbunny .incubator .jsonpointer .constants import JSON_SCALARS , SCALAR_TYPES , JSON_VALUES , OPEN_BRACE , \
45 CLOSE_BRACE , \
56 SPACE , COMMA , EMPTY_STRING , CLOSE_BRACKET , OPEN_BRACKET
67
8+ _logger = logging .getLogger (__name__ )
9+
710# todo recursive code for printing list and dict members needs to detect cycles and have a maximum recursion depth
811class FormatFlags (NamedTuple ):
912 """Flags for various pretty printing options for Python nested JSON objects.
1013
11- Standard defaults designed for debugging small nested dicts, and as_json_format() is useful for initializing
12- flags for printing in a json compatible format.
14+ The default flags are designed for debugging small nested dicts, and as_json_format() is useful for initializing
15+ flags for printing in a JSON- compatible format.
1316
1417 The various "with_xxx()" methods make a copy of this instance's flags and allow you to set a specific flag.
1518 """
@@ -18,11 +21,14 @@ class FormatFlags(NamedTuple):
1821 use_repr : bool = False # when True format strings with str() instead of repr()
1922 format_json : bool = False # when True use "null" for "None" and "true" and "false" for True and False
2023 indent : int = 2 # number of spaces to indent each level of nesting
21- single_line : bool = True # when True format output as single line, when False over multiple lines
22- # when True do not insert commas after list and dict item elements
23- # note: when printing with single_line = True, if omit_commas is also True, output may be confusing
24- # as list and dict elements will have no obvious visual separation in the string, and parsing will be difficult
25- omit_commas : bool = False # when True do not insert commas after list and dict item elements
24+
25+ # single_line: When True, format output as a single line, when False format as multiple lines
26+ single_line : bool = True
27+
28+ # omit_commas: When True do not insert commas after list and dict item elements
29+ # note: when printing with single_line = True, if omit_commas is also True, output may be confusing since list and
30+ # dict elements will have no obvious visual separation in the string, and parsing will be more complicated
31+ omit_commas : bool = False # when True do not insert commas after `list` and `dict` item elements
2632
2733 @staticmethod
2834 def as_json_format () -> "FormatFlags" :
@@ -71,10 +77,10 @@ def with_omit_commas(self, omit_commas: bool) -> "FormatFlags":
7177
7278def format_scalar (scalar_obj : JSON_SCALARS , format_ : FormatFlags ) -> str :
7379 """Format the scalar_obj according to the Format flags.
74- If the scalar_obj is None, returns None, or "null" if format_json is True
75- If the scalar_obj is a bool, returns True/False, or "true"/"false" if format_json is True
76- Otherwise, return str(scalar_obj), or repr(scalar_obj) if use_repr is True
77- If quote_strings is True, enclose str objects in quotes (single or double as specified by format_.single_quotes))
80+ If the scalar_obj is None, return None. Return "null" if format_. format_json is True
81+ If the scalar_obj is a bool, return True/False, Return "true"/"false" if format_. format_json is True
82+ Otherwise, return str(scalar_obj). Return repr(scalar_obj) if format_. use_repr is True
83+ If quote_strings is True, enclose str objects in quotes (single or double as specified by format_.single_quotes)
7884
7985 FormatFlags :
8086 format_.quote_strings: If True, enclose str objects in quotes, no quotes if False
@@ -87,10 +93,10 @@ def format_scalar(scalar_obj: JSON_SCALARS, format_: FormatFlags) -> str:
8793
8894
8995
90- :param scalar_obj: the scalar object to format
96+ :param scalar_obj: The scalar object to format
9197 :param format_: Formatting flags used to specify formatting options
9298
93- :return: the formatted object as a string , or 'None'/'null' if scalar_obj argument is None
99+ :return: The formatted object as a str , or 'None'/'null' if the ` scalar_obj` argument is None
94100 """
95101 # no quotes used around JSON null, true, false literals
96102 if scalar_obj is None :
@@ -111,7 +117,8 @@ def format_scalar(scalar_obj: JSON_SCALARS, format_: FormatFlags) -> str:
111117 # repr doesn't always escape a double quote in a str!
112118 # E.g.: repr() returns 'k"l' for "k"l", instead of "k\"l" which makes the JSON decoder fail. Frustrating!
113119 # todo investigate rules for valid JSON strings and issues with repr()
114- s = s .replace ('"' , '\\ "' ) # todo do we need a regex for this to only replace " not preceeded by a \ ?
120+ # todo do we need a regex for this to only replace " not preceded by a \ ?
121+ s = s .replace ('"' , '\\ "' )
115122 else :
116123 s = str (scalar_obj )
117124 if isinstance (scalar_obj , str ) and format_ .quote_strings :
@@ -125,8 +132,8 @@ def _spacer(format_: FormatFlags, level: int) -> str:
125132 return SPACE * ( format_ .indent * level )
126133
127134def _is_empty_or_single_item (obj : JSON_VALUES ) -> bool :
128- """Recurse the list or dict and return true if every nested element is either empty,
129- or contains exactly one scalar list element or one key/value pair where value is a single scalar value.
135+ """Recurse the list or dict and return True if every nested element is either empty or contains
136+ exactly one scalar list element or one key/value pair where the value is a single scalar value.
130137 Another way to think of this is, if the structure does not require a comma, this method will return True
131138 E.g.
132139 [ [ [ ] ] ] , [ [ [ "one" ] ] ] - both return True
@@ -151,18 +158,38 @@ def _is_empty_or_single_item(obj: JSON_VALUES ) -> bool:
151158 else :
152159 return False
153160
154- def _pp_dict (json_dict : dict [str , JSON_VALUES ], format_ : FormatFlags , lines : list [str ], level : int = 0 ) -> list [str ]:
161+
162+ # noinspection DuplicatedCode
163+ def _pp_dict (json_dict : dict [str , JSON_VALUES ],
164+ format_ : FormatFlags ,
165+ lines : list [str ],
166+ level : int = 0 ,
167+ instance_ids : dict [int , JSON_VALUES ] | None = None ,
168+ ) -> list [str ]:
169+
155170 if not isinstance (json_dict , dict ):
156171 raise TypeError (f"Encountered non dict type: { type (json_dict )} " )
157172 if len (lines ) == 0 :
158173 lines .append ("" )
159174
160175 if lines [- 1 ] != EMPTY_STRING :
161- indent_str = SPACE * ( format_ .indent - 1 ) # current line already has text, so indent is relative to end of that text
176+ # the current line already has text, so indent is relative to the end of that text
177+ indent_str = SPACE * ( format_ .indent - 1 )
162178 elif len (lines ) == 1 or level == 0 :
163179 indent_str = EMPTY_STRING
164180 else :
165181 indent_str = _spacer (format_ , level )
182+
183+ if instance_ids is None :
184+ instance_ids = {} # keeps track of instance ids to detect circular references
185+
186+ if id (json_dict ) in instance_ids :
187+ # we have seen this list instance previously, cycle detected
188+ _logger .warning (f"Cycle detected in json_dict: { json_dict } " )
189+ lines [- 1 ] = f"{ indent_str } {{...}}"
190+ return lines
191+ else :
192+ instance_ids [id (json_dict )] = json_dict # save for future cycle detection
166193
167194 if len (json_dict ) == 0 :
168195 lines [- 1 ] += f"{ indent_str } { OPEN_BRACE } { SPACE } { CLOSE_BRACE } "
@@ -177,15 +204,16 @@ def _pp_dict(json_dict: dict[str, JSON_VALUES], format_: FormatFlags, lines: lis
177204
178205 comma = EMPTY_STRING if format_ .omit_commas else COMMA
179206 sp = SPACE if format_ .single_line else EMPTY_STRING
180- lines [- 1 ] += f"{ indent_str } { OPEN_BRACE } " # start of dict text : {
207+ lines [- 1 ] += f"{ indent_str } { OPEN_BRACE } " # start of the dict text: '{'
181208
182209 level += 1
183210 indent_str = _spacer (format_ , level )
184211 for index , (key , value ) in enumerate (json_dict .items ()):
185212
186213 # deal with commas
214+ # noinspection PyUnusedLocal
187215 first_item : bool = (index == 0 )
188- last_item : bool = (index == (len (json_dict ) - 1 )) # no comma after last item
216+ last_item : bool = (index == (len (json_dict ) - 1 )) # no comma after the last item
189217
190218 kf = format_scalar (key , format_ ) # formatted key
191219 if isinstance (value , SCALAR_TYPES ):
@@ -195,7 +223,7 @@ def _pp_dict(json_dict: dict[str, JSON_VALUES], format_: FormatFlags, lines: lis
195223 elif isinstance (value , list ):
196224 lines .append ("" )
197225 lines [- 1 ] = f"{ indent_str } { kf } :"
198- # special case is where value is either an empty list or a list with one scalar element:
226+ # special case is where the value is either an empty list or a list with one scalar element.
199227 # we can display this value on the same line as the key name.
200228 if len (value ) > 1 :
201229 lines .append ("" )
@@ -206,19 +234,19 @@ def _pp_dict(json_dict: dict[str, JSON_VALUES], format_: FormatFlags, lines: lis
206234 ...
207235 else :
208236 lines .append ("" )
209- _pp_list (value , format_ , lines , level )
237+ _pp_list (value , format_ , lines , level , instance_ids )
210238 elif isinstance (value , dict ):
211239 lines .append ("" )
212240 lines [- 1 ] = f"{ indent_str } { kf } :"
213- # special case is where value is either an empty dict or a dict with one key with a scalar value:
241+ # special case is where the value is either an empty dict or a dict with one key with a scalar value:
214242 # we can display the nested dict on the same line as the key name of the parent dict.
215243 if len (value ) > 1 :
216244 lines .append ("" )
217245 elif len (value ) == 1 :
218246 nk , nv = next (iter (value .items ()))
219247 if not isinstance (nv , SCALAR_TYPES ):
220248 lines .append ("" )
221- _pp_dict (value , format_ , lines , level )
249+ _pp_dict (value , format_ , lines , level , instance_ids )
222250
223251 if not last_item :
224252 lines [- 1 ] += comma
@@ -235,8 +263,13 @@ def _pp_dict(json_dict: dict[str, JSON_VALUES], format_: FormatFlags, lines: lis
235263
236264 return lines
237265
238-
239- def _pp_list (json_list : list [JSON_VALUES ], format_ : FormatFlags , lines : list [str ], level : int = 0 ) -> list [str ]:
266+ # noinspection DuplicatedCode
267+ def _pp_list (json_list : list [JSON_VALUES ],
268+ format_ : FormatFlags ,
269+ lines : list [str ],
270+ level : int = 0 ,
271+ instance_ids : dict [int , JSON_VALUES ] | None = None ,
272+ ) -> list [str ]:
240273
241274 if not isinstance (json_list , list ):
242275 raise TypeError (f"Encountered non list type: { type (json_list )} " )
@@ -245,11 +278,24 @@ def _pp_list(json_list: list[JSON_VALUES], format_: FormatFlags, lines: list[str
245278 lines .append ("" )
246279
247280 if lines [- 1 ] != EMPTY_STRING :
248- indent_str = SPACE * ( format_ .indent - 1 ) # current line already has text, so indent is relative to end of that text
281+ # the current line already has text, so indent is relative to the end of that text
282+ indent_str = SPACE * ( format_ .indent - 1 )
249283 elif len (lines ) == 1 or level == 0 :
250284 indent_str = EMPTY_STRING
251285 else :
252286 indent_str = _spacer (format_ , level )
287+
288+ if instance_ids is None :
289+ instance_ids = {} # keeps track of instance ids to detect circular references
290+
291+ if id (json_list ) in instance_ids :
292+ # we have seen this list instance previously, cycle detected
293+ _logger .warning (f"Cycle detected in json_list: { json_list } " )
294+ lines [- 1 ] = f"{ indent_str } [...]"
295+ return lines
296+ else :
297+ instance_ids [id (json_list )] = json_list # save for future cycle detection
298+
253299
254300 if len (json_list ) == 0 :
255301 lines [- 1 ] += f"{ indent_str } { OPEN_BRACKET } { SPACE } { CLOSE_BRACKET } "
@@ -268,20 +314,20 @@ def _pp_list(json_list: list[JSON_VALUES], format_: FormatFlags, lines: list[str
268314 for index , item in enumerate (json_list ):
269315
270316 first_item : bool = (index == 0 )
271- last_item : bool = (index == (len (json_list ) - 1 )) # no comma after last element
317+ last_item : bool = (index == (len (json_list ) - 1 )) # no comma after the last element
272318
273319 if isinstance (item , SCALAR_TYPES ):
274320 lines .append ("" )
275321 s = format_scalar (item , format_ )
276322 lines [- 1 ] = f"{ indent_str } { s } "
277323 elif isinstance (item , list ):
278- if not first_item : # if this is a new list starting inside of list, open brackets can go on same line
324+ if not first_item : # if this is a new list starting inside the list, open brackets can go on the same line
279325 lines .append ("" )
280- _pp_list (item , format_ , lines , level )
326+ _pp_list (item , format_ , lines , level , instance_ids )
281327 elif isinstance (item , dict ):
282- if not first_item : # if this is a new dict starting inside of list, open brackets can go on same line
328+ if not first_item : # if this is a new dict starting inside the list, open brackets can go on the same line
283329 lines .append ("" )
284- _pp_dict (item , format_ , lines , level )
330+ _pp_dict (item , format_ , lines , level , instance_ids )
285331
286332 if not last_item :
287333 lines [- 1 ] += comma
@@ -296,23 +342,30 @@ def _pp_list(json_list: list[JSON_VALUES], format_: FormatFlags, lines: list[str
296342
297343 return lines
298344
299- def pretty_print (json_obj : JSON_VALUES , format_ : FormatFlags , lines : list [str ], indent_level : int = 0 ) -> str :
345+ def pretty_print (json_obj : JSON_VALUES ,
346+ format_ : FormatFlags ,
347+ lines : list [str ] | None = None ,
348+ indent_level : int = 0 ,
349+ ) -> str :
300350 """Return the JSON value formatted as a str according to the flags in the format_ argument.
301351
302- Typically, an empty list is passed to this method. Each generated line of formatted outut is appended
303- to the lines list argument.
304- When this method returns, the lines argument will contain each line in the formatted str, or a single new
352+ Typically, an empty list is passed to this method. Each generated line of formatted output is appended
353+ to the ` lines` list argument.
354+ When this method returns, the ` lines` argument will contain each line in the formatted str, or a single new
305355 element if format_.single_line is True. These lines are then joined() and returned.
306356
307357 """
308-
309- lines .append ("" ) # so format methods will have a new starting line for output
358+ if lines is None or len (lines ) == 0 :
359+ lines = ["" ] # so format methods will have a new starting line for output
360+
361+ instance_ids : dict [int , JSON_VALUES ] = {} # keeps track of instance ids to detect circular references
362+
310363 if isinstance (json_obj , SCALAR_TYPES ):
311364 lines [- 1 ] = format_scalar (json_obj , format_ )
312365 elif isinstance (json_obj , list ):
313- _pp_list (json_obj , format_ , lines , indent_level )
366+ _pp_list (json_obj , format_ , lines , indent_level , instance_ids )
314367 elif isinstance (json_obj , dict ):
315- _pp_dict (json_obj , format_ , lines , indent_level )
368+ _pp_dict (json_obj , format_ , lines , indent_level , instance_ids )
316369 else :
317370 raise ValueError (f"Unsupported type: { type (json_obj )} " )
318371
0 commit comments