Skip to content

Commit e35585f

Browse files
committed
feat: Add JSON indentation option to decode() method
## Description Implements Issue #10 by adding optional JSON indentation support to the decode() function. Users can now request JSON-formatted output with configurable indentation by passing a json_indent parameter to DecodeOptions. ## Changes Made - Added json_indent parameter to DecodeOptions class - Updated decode() to return JSON string when json_indent is specified - Enhanced docstrings with usage examples - Added 11 comprehensive tests covering all use cases ## Type of Change - [x] New feature (non-breaking change that adds functionality) ## SPEC Compliance - [x] Non-breaking change (default behavior unchanged) - [x] Backward compatible (json_indent=None by default) - [x] Python-specific feature (output formatting enhancement) ## Testing - [x] All existing tests pass - [x] Added new tests (11 total, 100% pass rate) - [x] Comprehensive coverage (basic, nested, arrays, unicode, edge cases) - [x] No breaking changes ## Code Quality - [x] All type hints present (mypy passes) - [x] Ruff linting passes (0 errors) - [x] Code formatted (ruff format) - [x] Python 3.8+ compatible ## Example Usage ```python from toon_format import decode, DecodeOptions # Default behavior - returns Python object result = decode("name: Alice\nage: 30") # {'name': 'Alice', 'age': 30} # With JSON indentation - returns formatted JSON string result = decode("name: Alice\nage: 30", DecodeOptions(json_indent=2)) # {\n "name": "Alice",\n "age": 30\n} ```
1 parent 9c4f0c0 commit e35585f

3 files changed

Lines changed: 169 additions & 30 deletions

File tree

src/toon_format/decoder.py

Lines changed: 59 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
and validates array lengths and delimiters.
99
"""
1010

11+
import json
1112
from typing import Any, Dict, List, Optional, Tuple
1213

1314
from ._literal_utils import is_boolean_or_null_literal, is_numeric_literal
@@ -228,18 +229,38 @@ def split_key_value(line: str) -> Tuple[str, str]:
228229
return (key, value)
229230

230231

231-
def decode(input_str: str, options: Optional[DecodeOptions] = None) -> JsonValue:
232+
def decode(input_str: str, options: Optional[DecodeOptions] = None) -> Any:
232233
"""Decode a TOON-formatted string to a Python value.
233234
235+
This function parses TOON format and returns the decoded data. By default,
236+
it returns a Python object (dict, list, str, int, float, bool, or None).
237+
238+
The DecodeOptions.json_indent parameter is a Python-specific feature that
239+
enables returning a JSON-formatted string instead of a Python object.
240+
This is useful for applications that need pretty-printed JSON output.
241+
234242
Args:
235-
input_str: TOON-formatted string
236-
options: Optional decoding options
243+
input_str: TOON-formatted string to decode
244+
options: Optional DecodeOptions with indent, strict, and json_indent
245+
settings. If not provided, defaults are used (indent=2,
246+
strict=True, json_indent=None).
237247
238248
Returns:
239-
Decoded Python value
249+
By default (json_indent=None): Decoded Python value (object, array,
250+
string, number, boolean, or null).
251+
When json_indent is set: A JSON-formatted string with the specified
252+
indentation level. Example: DecodeOptions(json_indent=2) returns
253+
pretty-printed JSON with 2-space indentation.
240254
241255
Raises:
242-
ToonDecodeError: If input is malformed
256+
ToonDecodeError: If input is malformed or violates strict-mode rules
257+
258+
Example:
259+
>>> toon = "name: Alice\\nage: 30"
260+
>>> decode(toon)
261+
{'name': 'Alice', 'age': 30}
262+
>>> decode(toon, DecodeOptions(json_indent=2))
263+
'{\\n "name": "Alice",\\n "age": 30\\n}'
243264
"""
244265
if options is None:
245266
options = DecodeOptions()
@@ -273,32 +294,41 @@ def decode(input_str: str, options: Optional[DecodeOptions] = None) -> JsonValue
273294
# Check for empty input (per spec Section 8: empty/whitespace-only → empty object)
274295
non_blank_lines = [ln for ln in lines if not ln.is_blank]
275296
if not non_blank_lines:
276-
return {}
277-
278-
# Determine root form (Section 5)
279-
first_line = non_blank_lines[0]
280-
281-
# Check if it's a root array header
282-
header_info = parse_header(first_line.content)
283-
if header_info is not None and header_info[0] is None: # No key = root array
284-
# Root array
285-
return decode_array(lines, 0, 0, header_info, strict)
297+
result: Any = {}
298+
else:
299+
# Determine root form (Section 5)
300+
first_line = non_blank_lines[0]
301+
302+
# Check if it's a root array header
303+
header_info = parse_header(first_line.content)
304+
if header_info is not None and header_info[0] is None: # No key = root array
305+
# Root array
306+
result = decode_array(lines, 0, 0, header_info, strict)
307+
else:
308+
# Check if it's a single primitive
309+
if len(non_blank_lines) == 1:
310+
line_content = first_line.content
311+
# Check if it's not a key-value line
312+
try:
313+
split_key_value(line_content)
314+
# It's a key-value, so root object
315+
result = decode_object(lines, 0, 0, strict)
316+
except ToonDecodeError:
317+
# Not a key-value, check if it's a header
318+
if header_info is None:
319+
# Single primitive
320+
result = parse_primitive(line_content)
321+
else:
322+
result = decode_object(lines, 0, 0, strict)
323+
else:
324+
# Otherwise, root object
325+
result = decode_object(lines, 0, 0, strict)
286326

287-
# Check if it's a single primitive
288-
if len(non_blank_lines) == 1:
289-
line_content = first_line.content
290-
# Check if it's not a key-value line
291-
try:
292-
split_key_value(line_content)
293-
# It's a key-value, so root object
294-
except ToonDecodeError:
295-
# Not a key-value, check if it's a header
296-
if header_info is None:
297-
# Single primitive
298-
return parse_primitive(line_content)
327+
# If json_indent is specified, return JSON-formatted string
328+
if options.json_indent is not None:
329+
return json.dumps(result, indent=options.json_indent, ensure_ascii=False)
299330

300-
# Otherwise, root object
301-
return decode_object(lines, 0, 0, strict)
331+
return result
302332

303333

304334
def decode_object(

src/toon_format/types.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,26 @@ class DecodeOptions:
5252
5353
Attributes:
5454
indent: Number of spaces per indentation level (default: 2)
55+
Used for parsing TOON format.
5556
strict: Enable strict validation (default: True)
57+
Enforces spec conformance checks.
58+
json_indent: Optional number of spaces for JSON output formatting
59+
(default: None). When set, decode() returns a JSON-formatted
60+
string instead of a Python object. This is a Python-specific
61+
feature for convenient output formatting. When None, returns
62+
a Python object as normal. Pass an integer (e.g., 2 or 4)
63+
to enable pretty-printed JSON output.
5664
"""
5765

58-
def __init__(self, indent: int = 2, strict: bool = True) -> None:
66+
def __init__(
67+
self,
68+
indent: int = 2,
69+
strict: bool = True,
70+
json_indent: Union[int, None] = None,
71+
) -> None:
5972
self.indent = indent
6073
self.strict = strict
74+
self.json_indent = json_indent
6175

6276

6377
# Depth type for tracking indentation level

tests/test_api.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,3 +286,98 @@ def test_roundtrip_with_length_marker(self):
286286
toon = encode(original, {"lengthMarker": "#"})
287287
decoded = decode(toon)
288288
assert decoded == original
289+
290+
291+
class TestDecodeJSONIndentation:
292+
"""Test decode() JSON indentation feature (Issue #10)."""
293+
294+
def test_decode_with_json_indent_returns_string(self):
295+
"""decode() with json_indent should return JSON string."""
296+
toon = "id: 123\nname: Alice"
297+
options = DecodeOptions(json_indent=2)
298+
result = decode(toon, options)
299+
assert isinstance(result, str)
300+
# Verify it's valid JSON
301+
import json
302+
303+
parsed = json.loads(result)
304+
assert parsed == {"id": 123, "name": "Alice"}
305+
306+
def test_decode_with_json_indent_2(self):
307+
"""decode() with json_indent=2 should format with 2 spaces."""
308+
toon = "id: 123\nname: Alice"
309+
result = decode(toon, DecodeOptions(json_indent=2))
310+
expected = '{\n "id": 123,\n "name": "Alice"\n}'
311+
assert result == expected
312+
313+
def test_decode_with_json_indent_4(self):
314+
"""decode() with json_indent=4 should format with 4 spaces."""
315+
toon = "id: 123\nname: Alice"
316+
result = decode(toon, DecodeOptions(json_indent=4))
317+
expected = '{\n "id": 123,\n "name": "Alice"\n}'
318+
assert result == expected
319+
320+
def test_decode_with_json_indent_nested(self):
321+
"""decode() with json_indent should handle nested structures."""
322+
toon = "user:\n name: Alice\n age: 30"
323+
result = decode(toon, DecodeOptions(json_indent=2))
324+
expected = '{\n "user": {\n "name": "Alice",\n "age": 30\n }\n}'
325+
assert result == expected
326+
327+
def test_decode_with_json_indent_array(self):
328+
"""decode() with json_indent should handle arrays."""
329+
toon = "items[2]: apple,banana"
330+
result = decode(toon, DecodeOptions(json_indent=2))
331+
expected = '{\n "items": [\n "apple",\n "banana"\n ]\n}'
332+
assert result == expected
333+
334+
def test_decode_with_json_indent_none_returns_object(self):
335+
"""decode() with json_indent=None should return Python object."""
336+
toon = "id: 123\nname: Alice"
337+
options = DecodeOptions(json_indent=None)
338+
result = decode(toon, options)
339+
assert isinstance(result, dict)
340+
assert result == {"id": 123, "name": "Alice"}
341+
342+
def test_decode_with_json_indent_default_returns_object(self):
343+
"""decode() without json_indent should return Python object (default)."""
344+
toon = "id: 123\nname: Alice"
345+
result = decode(toon)
346+
assert isinstance(result, dict)
347+
assert result == {"id": 123, "name": "Alice"}
348+
349+
def test_decode_json_indent_with_unicode(self):
350+
"""decode() with json_indent should preserve unicode characters."""
351+
toon = 'name: "José"'
352+
result = decode(toon, DecodeOptions(json_indent=2))
353+
assert "José" in result
354+
import json
355+
356+
parsed = json.loads(result)
357+
assert parsed["name"] == "José"
358+
359+
def test_decode_json_indent_empty_object(self):
360+
"""decode() with json_indent on empty input should return empty object JSON."""
361+
result = decode("", DecodeOptions(json_indent=2))
362+
assert result == "{}"
363+
364+
def test_decode_json_indent_single_primitive(self):
365+
"""decode() with json_indent on single primitive should return JSON number."""
366+
result = decode("42", DecodeOptions(json_indent=2))
367+
assert result == "42"
368+
369+
def test_decode_json_indent_complex_nested(self):
370+
"""decode() with json_indent should handle complex nested structures."""
371+
toon = """users[2]{id,name}:
372+
1,Alice
373+
2,Bob
374+
metadata:
375+
version: 1
376+
active: true"""
377+
result = decode(toon, DecodeOptions(json_indent=2))
378+
import json
379+
380+
parsed = json.loads(result)
381+
assert parsed["users"][0] == {"id": 1, "name": "Alice"}
382+
assert parsed["metadata"]["version"] == 1
383+
assert parsed["metadata"]["active"] is True

0 commit comments

Comments
 (0)