Skip to content

Commit 47ddaac

Browse files
authored
Merge pull request #3 from rob-ross/cycle_tests
cycle_tests for pretty_printer
2 parents 80dcfda + e3a34a8 commit 47ddaac

File tree

8 files changed

+748
-596
lines changed

8 files changed

+748
-596
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Python Tests
2+
3+
on:
4+
push:
5+
branches: [ main, develop ]
6+
pull_request:
7+
branches: [ main, develop ]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- uses: actions/checkout@v3
15+
- name: Set up Python
16+
uses: actions/setup-python@v4
17+
with:
18+
python-version: '3.12'
19+
20+
- name: Install dependencies
21+
run: |
22+
python -m pip install --upgrade pip
23+
# Install the package in development mode
24+
pip install -e .
25+
# Install test dependencies
26+
pip install .[test]
27+
28+
29+
- name: Run tests
30+
run: |
31+
pytest -v

.idea/copyright/profiles_settings.xml

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

killerbunny/incubator/jsonpointer/pretty_printer.py

Lines changed: 93 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1+
import logging
12
from typing import NamedTuple
23

34
from 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
811
class 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

7278
def 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

127134
def _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

killerbunny/jpath/__init__.py

Lines changed: 0 additions & 6 deletions
This file was deleted.

killerbunny/main.py

Lines changed: 0 additions & 43 deletions
This file was deleted.

0 commit comments

Comments
 (0)