# /// zerodep # version = "0.3.0" # deps = [] # tier = "simple" # category = "serialization" # note = "Install/update via `zerodep add jsonc`" # /// """JSONC (JSON with Comments) parser — zero dependencies, stdlib only, Python 3.10+. Part of zerodep: https://github.com/Oaklight/zerodep Copyright (c) 2026 Peng Ding. MIT License. Drop-in replacement for commentjson / stdlib json with JSONC support. Supports: - Single-line comments: ``//`` and ``#`` - Block comments: ``/* ... */`` - Trailing commas in objects and arrays - All standard JSON types Example:: loads('{"a": 1, // comment\n"b": 2}') # {'a': 1, 'b': 2} load(open("config.jsonc")) # {...} """ from __future__ import annotations import json import re from typing import IO, Any __all__ = [ "JSONCDecodeError", "loads", "load", "dumps", "dump", ] # ── Comment stripping ───────────────────────────────────────────────────────── # Matches (in priority order): # 1. Double-quoted strings (group 1) — preserved as-is # 2. Single-line // comments — removed # 3. Single-line # comments — removed # 4. Block /* ... */ comments — removed _COMMENT_RE = re.compile( r""" ( "(?:[^"\\]|\\.)*" ) # group 1: double-quoted string (keep) | //[^\n]* # single-line // comment | \#[^\n]* # single-line # comment | /\*[\s\S]*?\*/ # block /* */ comment """, re.VERBOSE | re.MULTILINE, ) # Trailing comma before closing ] or } _TRAILING_COMMA_RE = re.compile( r""" ( "(?:[^"\\]|\\.)*" ) # group 1: double-quoted string (keep) | ,\s*(?=[}\]]) # comma followed by optional whitespace then } or ] """, re.VERBOSE, ) def _strip_comments(text: str) -> str: """Remove ``//``, ``#``, and ``/* */`` comments, preserving strings.""" def _replace(m: re.Match[str]) -> str: if m.group(1) is not None: return m.group(1) return "" return _COMMENT_RE.sub(_replace, text) def _strip_trailing_commas(text: str) -> str: """Remove trailing commas before ``}`` and ``]``, preserving strings.""" def _replace(m: re.Match[str]) -> str: if m.group(1) is not None: return m.group(1) return "" return _TRAILING_COMMA_RE.sub(_replace, text) def _preprocess(text: str) -> str: """Strip comments and trailing commas from JSONC text.""" text = _strip_comments(text) text = _strip_trailing_commas(text) return text # ── Line-number error mapping ──────────────────────────────────────────────── def _remap_error_position( original: str, cleaned: str, clean_pos: int ) -> tuple[int, int]: """Map a character offset in *cleaned* text back to *original* line/col. Returns (line, column), both 1-based. """ # Build a mapping from cleaned-offset → original-offset by replaying the # comment-stripping regex. Each matched region in the original either maps # 1:1 (strings) or collapses to zero length (comments). orig_idx = 0 clean_idx = 0 mapping: list[tuple[int, int, int]] = [] # (clean_start, clean_end, orig_start) for m in _COMMENT_RE.finditer(original): # Characters before this match map 1:1 pre_len = m.start() - orig_idx if pre_len > 0: mapping.append((clean_idx, clean_idx + pre_len, orig_idx)) clean_idx += pre_len orig_idx = m.start() if m.group(1) is not None: # Preserved string — maps 1:1 span_len = m.end() - m.start() mapping.append((clean_idx, clean_idx + span_len, orig_idx)) clean_idx += span_len # else: comment — removed, clean_idx doesn't advance orig_idx = m.end() # Tail after last match tail_len = len(original) - orig_idx if tail_len > 0: mapping.append((clean_idx, clean_idx + tail_len, orig_idx)) # Look up clean_pos in the mapping orig_offset = clean_pos # fallback for cs, ce, os_ in mapping: if cs <= clean_pos < ce: orig_offset = os_ + (clean_pos - cs) break if clean_pos < cs: orig_offset = os_ break # Convert original offset to line/col line = original[:orig_offset].count("\n") + 1 last_nl = original.rfind("\n", 0, orig_offset) col = orig_offset - last_nl # 1-based return line, col class JSONCDecodeError(json.JSONDecodeError): """Error raised when JSONC parsing fails. Provides line and column numbers relative to the original JSONC source (before comment/trailing-comma stripping). """ # ── Public API ──────────────────────────────────────────────────────────────── def loads( text: str, *, cls: type[json.JSONDecoder] | None = None, object_hook: Any = None, parse_float: Any = None, parse_int: Any = None, parse_constant: Any = None, object_pairs_hook: Any = None, **kwargs: Any, ) -> Any: """Deserialize a JSONC string to a Python object. Strips ``//``, ``#``, and ``/* */`` comments and trailing commas before delegating to :func:`json.loads`. Args: text: JSONC source string. cls: Custom JSON decoder class. object_hook: Called with the result of any object literal decoded. parse_float: Called with every JSON float string decoded. parse_int: Called with every JSON int string decoded. parse_constant: Called with ``-Infinity``, ``Infinity``, ``NaN``. object_pairs_hook: Called with an ordered list of pairs. **kwargs: Additional keyword arguments passed to :func:`json.loads`. Returns: Deserialized Python object. Raises: JSONCDecodeError: If the text is not valid JSONC. """ cleaned = _preprocess(text) try: return json.loads( cleaned, cls=cls, object_hook=object_hook, parse_float=parse_float, parse_int=parse_int, parse_constant=parse_constant, object_pairs_hook=object_pairs_hook, **kwargs, ) except json.JSONDecodeError as exc: line, col = _remap_error_position(text, cleaned, exc.pos) raise JSONCDecodeError(exc.msg, text, exc.pos) from None def load( fp: IO[str], *, cls: type[json.JSONDecoder] | None = None, object_hook: Any = None, parse_float: Any = None, parse_int: Any = None, parse_constant: Any = None, object_pairs_hook: Any = None, **kwargs: Any, ) -> Any: """Deserialize a JSONC file to a Python object. Args: fp: A text file-like object containing JSONC. cls: Custom JSON decoder class. object_hook: Called with the result of any object literal decoded. parse_float: Called with every JSON float string decoded. parse_int: Called with every JSON int string decoded. parse_constant: Called with ``-Infinity``, ``Infinity``, ``NaN``. object_pairs_hook: Called with an ordered list of pairs. **kwargs: Additional keyword arguments passed to :func:`json.loads`. Returns: Deserialized Python object. Raises: JSONCDecodeError: If the content is not valid JSONC. """ return loads( fp.read(), cls=cls, object_hook=object_hook, parse_float=parse_float, parse_int=parse_int, parse_constant=parse_constant, object_pairs_hook=object_pairs_hook, **kwargs, ) def dumps( obj: Any, *, skipkeys: bool = False, ensure_ascii: bool = True, check_circular: bool = True, allow_nan: bool = True, cls: type[json.JSONEncoder] | None = None, indent: int | str | None = None, separators: tuple[str, str] | None = None, default: Any = None, sort_keys: bool = False, **kwargs: Any, ) -> str: """Serialize a Python object to a JSON string. This is a pass-through to :func:`json.dumps` for API compatibility. Args: obj: Python object to serialize. skipkeys: Skip keys that are not basic types. ensure_ascii: Escape non-ASCII characters. check_circular: Check for circular references. allow_nan: Allow ``NaN``, ``Infinity``, ``-Infinity``. cls: Custom JSON encoder class. indent: Indentation level for pretty-printing. separators: Item and key separators. default: Called for objects that are not serializable. sort_keys: Sort dictionary keys. **kwargs: Additional keyword arguments passed to :func:`json.dumps`. Returns: JSON string. """ return json.dumps( obj, skipkeys=skipkeys, ensure_ascii=ensure_ascii, check_circular=check_circular, allow_nan=allow_nan, cls=cls, indent=indent, separators=separators, default=default, sort_keys=sort_keys, **kwargs, ) def dump( obj: Any, fp: IO[str], *, skipkeys: bool = False, ensure_ascii: bool = True, check_circular: bool = True, allow_nan: bool = True, cls: type[json.JSONEncoder] | None = None, indent: int | str | None = None, separators: tuple[str, str] | None = None, default: Any = None, sort_keys: bool = False, **kwargs: Any, ) -> None: """Serialize a Python object to a JSON file. This is a pass-through to :func:`json.dump` for API compatibility. Args: obj: Python object to serialize. fp: A text file-like object to write to. skipkeys: Skip keys that are not basic types. ensure_ascii: Escape non-ASCII characters. check_circular: Check for circular references. allow_nan: Allow ``NaN``, ``Infinity``, ``-Infinity``. cls: Custom JSON encoder class. indent: Indentation level for pretty-printing. separators: Item and key separators. default: Called for objects that are not serializable. sort_keys: Sort dictionary keys. **kwargs: Additional keyword arguments passed to :func:`json.dump`. """ json.dump( obj, fp, skipkeys=skipkeys, ensure_ascii=ensure_ascii, check_circular=check_circular, allow_nan=allow_nan, cls=cls, indent=indent, separators=separators, default=default, sort_keys=sort_keys, **kwargs, )