jsonc-document 2026.10.15__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jsonc_document/__init__.py +27 -0
- jsonc_document/_lexer.py +201 -0
- jsonc_document/_model.py +142 -0
- jsonc_document/_parser.py +264 -0
- jsonc_document/py.typed +0 -0
- jsonc_document/types.py +7 -0
- jsonc_document-2026.10.15.dist-info/METADATA +50 -0
- jsonc_document-2026.10.15.dist-info/RECORD +11 -0
- jsonc_document-2026.10.15.dist-info/WHEEL +5 -0
- jsonc_document-2026.10.15.dist-info/licenses/LICENSE +21 -0
- jsonc_document-2026.10.15.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Typed JSONC parsing with comments and original source spans."""
|
|
2
|
+
|
|
3
|
+
from jsonc_document._lexer import tokenize
|
|
4
|
+
from jsonc_document._model import (
|
|
5
|
+
Document,
|
|
6
|
+
ErrorKind,
|
|
7
|
+
JSONCDecodeError,
|
|
8
|
+
Node,
|
|
9
|
+
Token,
|
|
10
|
+
TokenKind,
|
|
11
|
+
)
|
|
12
|
+
from jsonc_document._parser import loads, parse
|
|
13
|
+
from jsonc_document.types import Scalar, Value
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Document",
|
|
17
|
+
"ErrorKind",
|
|
18
|
+
"JSONCDecodeError",
|
|
19
|
+
"Node",
|
|
20
|
+
"Scalar",
|
|
21
|
+
"Token",
|
|
22
|
+
"TokenKind",
|
|
23
|
+
"Value",
|
|
24
|
+
"loads",
|
|
25
|
+
"parse",
|
|
26
|
+
"tokenize",
|
|
27
|
+
]
|
jsonc_document/_lexer.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""JSONC lexical grammar with original source spans."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
|
|
7
|
+
from jsonc_document._model import (
|
|
8
|
+
ErrorKind,
|
|
9
|
+
JSONCDecodeError,
|
|
10
|
+
Token,
|
|
11
|
+
TokenKind,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_CONTROL_BOUNDARY = 0x20
|
|
15
|
+
_UNICODE_ESCAPE_DIGITS = 4
|
|
16
|
+
_WHITE_SPACE = " \t\r\n"
|
|
17
|
+
_NUMBER = re.compile(
|
|
18
|
+
pattern=r"-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?"
|
|
19
|
+
)
|
|
20
|
+
_LITERAL = re.compile(pattern=r"true|false|null")
|
|
21
|
+
_ESCAPES = {
|
|
22
|
+
'"': '"',
|
|
23
|
+
"\\": "\\",
|
|
24
|
+
"/": "/",
|
|
25
|
+
"b": "\b",
|
|
26
|
+
"f": "\f",
|
|
27
|
+
"n": "\n",
|
|
28
|
+
"r": "\r",
|
|
29
|
+
"t": "\t",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _error(*, source: str, position: int, message: str) -> JSONCDecodeError:
|
|
34
|
+
"""Build a position-bearing lexical error."""
|
|
35
|
+
return JSONCDecodeError(
|
|
36
|
+
message=message,
|
|
37
|
+
source=source,
|
|
38
|
+
position=position,
|
|
39
|
+
reason=ErrorKind.SYNTAX,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _string(*, source: str, start: int) -> tuple[int, str]:
|
|
44
|
+
"""Read a double-quoted string and decode JSON escape sequences."""
|
|
45
|
+
index = start + 1
|
|
46
|
+
characters: list[str] = []
|
|
47
|
+
while index < len(source):
|
|
48
|
+
char = source[index]
|
|
49
|
+
index += 1
|
|
50
|
+
if char == '"':
|
|
51
|
+
decoded = "".join(characters)
|
|
52
|
+
return index, decoded.encode(
|
|
53
|
+
encoding="utf-16", errors="surrogatepass"
|
|
54
|
+
).decode(encoding="utf-16", errors="surrogatepass")
|
|
55
|
+
if ord(char) < _CONTROL_BOUNDARY:
|
|
56
|
+
raise _error(
|
|
57
|
+
source=source,
|
|
58
|
+
position=index - 1,
|
|
59
|
+
message="Unescaped control character in string",
|
|
60
|
+
)
|
|
61
|
+
if char != "\\":
|
|
62
|
+
characters.append(char)
|
|
63
|
+
continue
|
|
64
|
+
if index == len(source):
|
|
65
|
+
break
|
|
66
|
+
char = source[index]
|
|
67
|
+
index += 1
|
|
68
|
+
if char == "u":
|
|
69
|
+
digits = source[index : index + _UNICODE_ESCAPE_DIGITS]
|
|
70
|
+
if len(digits) != _UNICODE_ESCAPE_DIGITS or any(
|
|
71
|
+
digit not in "0123456789abcdefABCDEF" for digit in digits
|
|
72
|
+
):
|
|
73
|
+
raise _error(
|
|
74
|
+
source=source,
|
|
75
|
+
position=index,
|
|
76
|
+
message="Invalid Unicode escape",
|
|
77
|
+
)
|
|
78
|
+
characters.append(chr(int(digits, base=16)))
|
|
79
|
+
index += _UNICODE_ESCAPE_DIGITS
|
|
80
|
+
elif char in _ESCAPES:
|
|
81
|
+
characters.append(_ESCAPES[char])
|
|
82
|
+
else:
|
|
83
|
+
raise _error(
|
|
84
|
+
source=source,
|
|
85
|
+
position=index - 1,
|
|
86
|
+
message="Invalid string escape",
|
|
87
|
+
)
|
|
88
|
+
raise _error(source=source, position=start, message="Unterminated string")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _number_value(*, source: str, start: int, raw: str) -> int | float:
|
|
92
|
+
"""Decode a number while rejecting finite overflow and underflow."""
|
|
93
|
+
if raw == "-0":
|
|
94
|
+
return -0.0
|
|
95
|
+
try:
|
|
96
|
+
if not any(char in raw for char in ".eE"):
|
|
97
|
+
return int(raw, base=10)
|
|
98
|
+
value = float(raw)
|
|
99
|
+
except ValueError as exc:
|
|
100
|
+
raise JSONCDecodeError(
|
|
101
|
+
source=source,
|
|
102
|
+
position=start,
|
|
103
|
+
reason=ErrorKind.INTEGER_DIGITS,
|
|
104
|
+
message="Integer exceeds the interpreter's supported digit limit",
|
|
105
|
+
) from exc
|
|
106
|
+
significant = (
|
|
107
|
+
raw.lstrip("-").lower().split(sep="e")[0].replace(".", "").strip("0")
|
|
108
|
+
)
|
|
109
|
+
if math.isinf(value) or (value == 0 and bool(significant)):
|
|
110
|
+
raise JSONCDecodeError(
|
|
111
|
+
source=source,
|
|
112
|
+
position=start,
|
|
113
|
+
reason=ErrorKind.NUMBER_RANGE,
|
|
114
|
+
message="Finite numeric token is outside binary64 range",
|
|
115
|
+
)
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _block_comment_end(*, source: str, index: int) -> int:
|
|
120
|
+
"""Find a block comment's terminator or report its original start."""
|
|
121
|
+
close = source.find("*/", index + 2)
|
|
122
|
+
if close == -1:
|
|
123
|
+
raise _error(
|
|
124
|
+
source=source, position=index, message="Unterminated block comment"
|
|
125
|
+
)
|
|
126
|
+
return close + 2
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _whitespace_end(*, source: str, index: int) -> int:
|
|
130
|
+
"""Find the end of consecutive JSON whitespace."""
|
|
131
|
+
index += 1
|
|
132
|
+
while index < len(source) and source[index] in _WHITE_SPACE:
|
|
133
|
+
index += 1
|
|
134
|
+
return index
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _line_comment_end(*, source: str, index: int) -> int:
|
|
138
|
+
"""Find a CR or LF terminating a line comment."""
|
|
139
|
+
index += 2
|
|
140
|
+
while index < len(source) and source[index] not in "\r\n":
|
|
141
|
+
index += 1
|
|
142
|
+
return index
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def tokenize(*, source: str) -> Iterator[Token]:
|
|
146
|
+
"""
|
|
147
|
+
Yield every source token, including comments and whitespace.
|
|
148
|
+
|
|
149
|
+
Lexical validity is checked; use ``parse`` to validate the complete
|
|
150
|
+
document grammar.
|
|
151
|
+
"""
|
|
152
|
+
index = 0
|
|
153
|
+
while index < len(source):
|
|
154
|
+
start = index
|
|
155
|
+
char = source[index]
|
|
156
|
+
value: str | int | float | bool | None = None
|
|
157
|
+
if char in _WHITE_SPACE:
|
|
158
|
+
kind = TokenKind.WHITESPACE
|
|
159
|
+
index = _whitespace_end(source=source, index=index)
|
|
160
|
+
elif source.startswith("//", index):
|
|
161
|
+
kind = TokenKind.COMMENT
|
|
162
|
+
index = _line_comment_end(source=source, index=index)
|
|
163
|
+
elif source.startswith("/*", index):
|
|
164
|
+
kind = TokenKind.COMMENT
|
|
165
|
+
index = _block_comment_end(source=source, index=index)
|
|
166
|
+
elif char in "{}[]:,":
|
|
167
|
+
kind = TokenKind.PUNCTUATION
|
|
168
|
+
index += 1
|
|
169
|
+
elif char == '"':
|
|
170
|
+
kind = TokenKind.STRING
|
|
171
|
+
index, value = _string(source=source, start=start)
|
|
172
|
+
elif char in "-0123456789":
|
|
173
|
+
kind = TokenKind.NUMBER
|
|
174
|
+
match = _NUMBER.match(string=source, pos=index)
|
|
175
|
+
if match is None:
|
|
176
|
+
raise _error(
|
|
177
|
+
source=source, position=start, message="Invalid number"
|
|
178
|
+
)
|
|
179
|
+
index = match.end()
|
|
180
|
+
value = _number_value(
|
|
181
|
+
source=source, start=start, raw=match.group()
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
kind = TokenKind.LITERAL
|
|
185
|
+
match = _LITERAL.match(string=source, pos=index)
|
|
186
|
+
if match is None:
|
|
187
|
+
raise _error(
|
|
188
|
+
source=source,
|
|
189
|
+
position=start,
|
|
190
|
+
message="Unexpected character",
|
|
191
|
+
)
|
|
192
|
+
index = match.end()
|
|
193
|
+
literals = {"true": True, "false": False, "null": None}
|
|
194
|
+
value = literals[match.group()]
|
|
195
|
+
yield Token(
|
|
196
|
+
kind=kind,
|
|
197
|
+
text=source[start:index],
|
|
198
|
+
start=start,
|
|
199
|
+
end=index,
|
|
200
|
+
value=value,
|
|
201
|
+
)
|
jsonc_document/_model.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Public document, token, and syntax tree models."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import enum
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import override
|
|
8
|
+
|
|
9
|
+
from jsonc_document.types import Scalar, Value
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TokenKind(enum.Enum):
|
|
13
|
+
"""The lexical categories retained in a document."""
|
|
14
|
+
|
|
15
|
+
PUNCTUATION = enum.auto()
|
|
16
|
+
STRING = enum.auto()
|
|
17
|
+
LITERAL = enum.auto()
|
|
18
|
+
NUMBER = enum.auto()
|
|
19
|
+
COMMENT = enum.auto()
|
|
20
|
+
WHITESPACE = enum.auto()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
24
|
+
class Token:
|
|
25
|
+
"""A token's original spelling and half-open Unicode source span."""
|
|
26
|
+
|
|
27
|
+
kind: TokenKind
|
|
28
|
+
text: str
|
|
29
|
+
start: int
|
|
30
|
+
end: int
|
|
31
|
+
value: Scalar
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
35
|
+
class Node:
|
|
36
|
+
"""
|
|
37
|
+
A value and its syntax tree, retaining object keys in source order.
|
|
38
|
+
|
|
39
|
+
``children`` contains array elements or object member values.
|
|
40
|
+
``keys`` is empty for arrays and scalars. Collection delimiters are
|
|
41
|
+
included in the node's half-open source span.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
value: Value
|
|
45
|
+
start: int
|
|
46
|
+
end: int
|
|
47
|
+
children: tuple["Node", ...]
|
|
48
|
+
keys: tuple[Token, ...]
|
|
49
|
+
commas: tuple[Token, ...]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
53
|
+
class Document:
|
|
54
|
+
"""
|
|
55
|
+
A parsed JSONC source snapshot with every lexical token retained.
|
|
56
|
+
|
|
57
|
+
Mutating a returned Python collection does not rewrite source text.
|
|
58
|
+
Source editing is outside the document API.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
source: str
|
|
62
|
+
root: Node
|
|
63
|
+
tokens: tuple[Token, ...]
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def value(self) -> Value:
|
|
67
|
+
"""Return the decoded Python value."""
|
|
68
|
+
return self.root.value
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def comments(self) -> tuple[Token, ...]:
|
|
72
|
+
"""Return line and block comments in source order."""
|
|
73
|
+
return tuple(
|
|
74
|
+
token for token in self.tokens if token.kind == TokenKind.COMMENT
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def to_source(self) -> str:
|
|
78
|
+
"""Return the original source, preserving all spelling and spacing."""
|
|
79
|
+
return self.source
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ErrorKind(enum.Enum):
|
|
83
|
+
"""Structured error categories for applications adapting
|
|
84
|
+
diagnostics.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
SYNTAX = enum.auto()
|
|
88
|
+
DUPLICATE_KEY = enum.auto()
|
|
89
|
+
NUMBER_RANGE = enum.auto()
|
|
90
|
+
INTEGER_DIGITS = enum.auto()
|
|
91
|
+
DEPTH = enum.auto()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class JSONCDecodeError(json.JSONDecodeError):
|
|
95
|
+
"""
|
|
96
|
+
A syntax or data-policy error with original source coordinates.
|
|
97
|
+
|
|
98
|
+
``pos`` is a zero-based character offset. ``lineno`` and ``colno``
|
|
99
|
+
are one-based and recognize CR, LF, and CRLF.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
reason: ErrorKind
|
|
103
|
+
lineno: int
|
|
104
|
+
colno: int
|
|
105
|
+
args: tuple[object, ...]
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self, *, message: str, source: str, position: int, reason: ErrorKind
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Record a structured failure at its original source location."""
|
|
111
|
+
self.reason = reason
|
|
112
|
+
super().__init__(msg=message, doc=source, pos=position)
|
|
113
|
+
prefix = source[:position].replace("\r\n", "\n")
|
|
114
|
+
for ending in ("\r",):
|
|
115
|
+
prefix = prefix.replace(ending, "\n")
|
|
116
|
+
self.lineno = prefix.count("\n") + 1
|
|
117
|
+
self.colno = len(prefix.rsplit(sep="\n", maxsplit=1)[-1]) + 1
|
|
118
|
+
formatted = (
|
|
119
|
+
f"{message}: line {self.lineno} column {self.colno} "
|
|
120
|
+
f"(char {position})"
|
|
121
|
+
)
|
|
122
|
+
self.args = (formatted,)
|
|
123
|
+
|
|
124
|
+
@override
|
|
125
|
+
def __reduce__(
|
|
126
|
+
self,
|
|
127
|
+
) -> tuple[
|
|
128
|
+
Callable[..., "JSONCDecodeError"], tuple[str, str, int, ErrorKind]
|
|
129
|
+
]:
|
|
130
|
+
"""Retain source coordinates and reason when copying or
|
|
131
|
+
serializing.
|
|
132
|
+
"""
|
|
133
|
+
return _restore_error, (self.msg, self.doc, self.pos, self.reason)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _restore_error(
|
|
137
|
+
message: str, source: str, position: int, reason: ErrorKind
|
|
138
|
+
) -> JSONCDecodeError:
|
|
139
|
+
"""Restore a serialized exception through its keyword argument API."""
|
|
140
|
+
return JSONCDecodeError(
|
|
141
|
+
message=message, source=source, position=position, reason=reason
|
|
142
|
+
)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""An iterative JSONC parser, independent of Python's recursion limit."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import enum
|
|
5
|
+
|
|
6
|
+
from jsonc_document._lexer import tokenize
|
|
7
|
+
from jsonc_document._model import (
|
|
8
|
+
Document,
|
|
9
|
+
ErrorKind,
|
|
10
|
+
JSONCDecodeError,
|
|
11
|
+
Node,
|
|
12
|
+
Token,
|
|
13
|
+
TokenKind,
|
|
14
|
+
)
|
|
15
|
+
from jsonc_document.types import Value
|
|
16
|
+
|
|
17
|
+
_MAX_DEPTH = 512
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _State(enum.Enum):
|
|
21
|
+
"""The next grammar production expected inside a collection."""
|
|
22
|
+
|
|
23
|
+
KEY = enum.auto()
|
|
24
|
+
COLON = enum.auto()
|
|
25
|
+
VALUE = enum.auto()
|
|
26
|
+
ARRAY_VALUE = enum.auto()
|
|
27
|
+
SEPARATOR = enum.auto()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclasses.dataclass
|
|
31
|
+
class _Frame:
|
|
32
|
+
"""A collection currently being assembled."""
|
|
33
|
+
|
|
34
|
+
opening: Token
|
|
35
|
+
children: list[Node]
|
|
36
|
+
keys: list[Token]
|
|
37
|
+
commas: list[Token]
|
|
38
|
+
values: dict[str, Value]
|
|
39
|
+
pending_name: str
|
|
40
|
+
seen_keys: set[str]
|
|
41
|
+
pending_key: Token | None
|
|
42
|
+
state: _State
|
|
43
|
+
|
|
44
|
+
def expect(self, *, state: _State) -> None:
|
|
45
|
+
"""Record the next production expected by this frame."""
|
|
46
|
+
self.state = state
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclasses.dataclass
|
|
50
|
+
class _Parser:
|
|
51
|
+
"""Consume syntax tokens while retaining their original positions."""
|
|
52
|
+
|
|
53
|
+
source: str
|
|
54
|
+
tokens: tuple[Token, ...]
|
|
55
|
+
frames: list[_Frame]
|
|
56
|
+
root: Node | None
|
|
57
|
+
|
|
58
|
+
def error(
|
|
59
|
+
self, *, token: Token, message: str, reason: ErrorKind
|
|
60
|
+
) -> JSONCDecodeError:
|
|
61
|
+
"""Create an error at the offending syntax token."""
|
|
62
|
+
return JSONCDecodeError(
|
|
63
|
+
message=message,
|
|
64
|
+
source=self.source,
|
|
65
|
+
position=token.start,
|
|
66
|
+
reason=reason,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def accept(self, *, node: Node) -> None:
|
|
70
|
+
"""Attach a completed node to its parent or the document root."""
|
|
71
|
+
if not bool(self.frames):
|
|
72
|
+
self.root = node
|
|
73
|
+
return
|
|
74
|
+
frame = self.frames[-1]
|
|
75
|
+
frame.children.append(node)
|
|
76
|
+
if frame.pending_key is not None:
|
|
77
|
+
frame.keys.append(frame.pending_key)
|
|
78
|
+
frame.values[frame.pending_name] = node.value
|
|
79
|
+
frame.pending_key = None
|
|
80
|
+
frame.expect(state=_State.SEPARATOR)
|
|
81
|
+
|
|
82
|
+
def close(self, *, token: Token) -> None:
|
|
83
|
+
"""Finish the current collection and attach its complete tree."""
|
|
84
|
+
frame = self.frames.pop()
|
|
85
|
+
value: Value
|
|
86
|
+
if frame.opening.text == "[":
|
|
87
|
+
value = [child.value for child in frame.children]
|
|
88
|
+
else:
|
|
89
|
+
value = frame.values
|
|
90
|
+
self.accept(
|
|
91
|
+
node=Node(
|
|
92
|
+
value=value,
|
|
93
|
+
start=frame.opening.start,
|
|
94
|
+
end=token.end,
|
|
95
|
+
children=tuple(frame.children),
|
|
96
|
+
keys=tuple(frame.keys),
|
|
97
|
+
commas=tuple(frame.commas),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def advance(self, *, token: Token) -> bool:
|
|
102
|
+
"""Consume collection punctuation and keys before reading a
|
|
103
|
+
value.
|
|
104
|
+
"""
|
|
105
|
+
frame = self.frames[-1]
|
|
106
|
+
closing = "]" if frame.opening.text == "[" else "}"
|
|
107
|
+
if frame.state == _State.SEPARATOR:
|
|
108
|
+
if token.text == closing:
|
|
109
|
+
self.close(token=token)
|
|
110
|
+
elif token.text == ",":
|
|
111
|
+
frame.commas.append(token)
|
|
112
|
+
frame.expect(
|
|
113
|
+
state=_State.ARRAY_VALUE if closing == "]" else _State.KEY
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
raise self.error(
|
|
117
|
+
token=token,
|
|
118
|
+
message="Expected comma or closing delimiter",
|
|
119
|
+
reason=ErrorKind.SYNTAX,
|
|
120
|
+
)
|
|
121
|
+
return True
|
|
122
|
+
if (
|
|
123
|
+
frame.state in {_State.KEY, _State.ARRAY_VALUE}
|
|
124
|
+
and token.text == closing
|
|
125
|
+
):
|
|
126
|
+
if bool(frame.commas) and len(frame.commas) == len(frame.children):
|
|
127
|
+
raise self.error(
|
|
128
|
+
token=token,
|
|
129
|
+
message="Trailing commas are not allowed",
|
|
130
|
+
reason=ErrorKind.SYNTAX,
|
|
131
|
+
)
|
|
132
|
+
self.close(token=token)
|
|
133
|
+
return True
|
|
134
|
+
if frame.state == _State.KEY:
|
|
135
|
+
self.read_key(token=token)
|
|
136
|
+
return True
|
|
137
|
+
if frame.state == _State.COLON:
|
|
138
|
+
if token.text != ":":
|
|
139
|
+
raise self.error(
|
|
140
|
+
token=token,
|
|
141
|
+
message="Expected colon",
|
|
142
|
+
reason=ErrorKind.SYNTAX,
|
|
143
|
+
)
|
|
144
|
+
frame.expect(state=_State.VALUE)
|
|
145
|
+
return True
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
def read_key(self, *, token: Token) -> None:
|
|
149
|
+
"""Decode a member name and reject repeated keys."""
|
|
150
|
+
frame = self.frames[-1]
|
|
151
|
+
if token.kind != TokenKind.STRING or not isinstance(token.value, str):
|
|
152
|
+
raise self.error(
|
|
153
|
+
token=token,
|
|
154
|
+
message="Expected an object member name",
|
|
155
|
+
reason=ErrorKind.SYNTAX,
|
|
156
|
+
)
|
|
157
|
+
if token.value in frame.seen_keys:
|
|
158
|
+
raise self.error(
|
|
159
|
+
token=token,
|
|
160
|
+
message=f"Duplicate key {token.value!r}",
|
|
161
|
+
reason=ErrorKind.DUPLICATE_KEY,
|
|
162
|
+
)
|
|
163
|
+
frame.seen_keys.add(token.value)
|
|
164
|
+
frame.pending_key = token
|
|
165
|
+
frame.pending_name = token.value
|
|
166
|
+
frame.expect(state=_State.COLON)
|
|
167
|
+
|
|
168
|
+
def read_value(self, *, token: Token) -> None:
|
|
169
|
+
"""Read a scalar or open another collection."""
|
|
170
|
+
if token.text in {"[", "{"}:
|
|
171
|
+
if len(self.frames) == _MAX_DEPTH:
|
|
172
|
+
raise self.error(
|
|
173
|
+
token=token,
|
|
174
|
+
message="Input exceeds the supported nesting depth",
|
|
175
|
+
reason=ErrorKind.DEPTH,
|
|
176
|
+
)
|
|
177
|
+
self.frames.append(
|
|
178
|
+
_Frame(
|
|
179
|
+
opening=token,
|
|
180
|
+
children=[],
|
|
181
|
+
keys=[],
|
|
182
|
+
commas=[],
|
|
183
|
+
values={},
|
|
184
|
+
pending_name="",
|
|
185
|
+
seen_keys=set(),
|
|
186
|
+
pending_key=None,
|
|
187
|
+
state=_State.ARRAY_VALUE
|
|
188
|
+
if token.text == "["
|
|
189
|
+
else _State.KEY,
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
return
|
|
193
|
+
value: Value = token.value
|
|
194
|
+
if token.kind not in {
|
|
195
|
+
TokenKind.NUMBER,
|
|
196
|
+
TokenKind.STRING,
|
|
197
|
+
TokenKind.LITERAL,
|
|
198
|
+
}:
|
|
199
|
+
raise self.error(
|
|
200
|
+
token=token,
|
|
201
|
+
message="Expected a JSONC value",
|
|
202
|
+
reason=ErrorKind.SYNTAX,
|
|
203
|
+
)
|
|
204
|
+
self.accept(
|
|
205
|
+
node=Node(
|
|
206
|
+
value=value,
|
|
207
|
+
start=token.start,
|
|
208
|
+
end=token.end,
|
|
209
|
+
children=(),
|
|
210
|
+
keys=(),
|
|
211
|
+
commas=(),
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def parse(self) -> Node:
|
|
216
|
+
"""Build a complete syntax tree or report the first invalid
|
|
217
|
+
token.
|
|
218
|
+
"""
|
|
219
|
+
for token in self.tokens:
|
|
220
|
+
if self.root is not None and not bool(self.frames):
|
|
221
|
+
raise self.error(
|
|
222
|
+
token=token,
|
|
223
|
+
message="Unexpected data after the root value",
|
|
224
|
+
reason=ErrorKind.SYNTAX,
|
|
225
|
+
)
|
|
226
|
+
if bool(self.frames) and self.advance(token=token):
|
|
227
|
+
continue
|
|
228
|
+
self.read_value(token=token)
|
|
229
|
+
if self.root is None:
|
|
230
|
+
message = (
|
|
231
|
+
"Unterminated collection"
|
|
232
|
+
if bool(self.frames)
|
|
233
|
+
else "Expected a JSONC value"
|
|
234
|
+
)
|
|
235
|
+
raise JSONCDecodeError(
|
|
236
|
+
message=message,
|
|
237
|
+
source=self.source,
|
|
238
|
+
position=len(self.source),
|
|
239
|
+
reason=ErrorKind.SYNTAX,
|
|
240
|
+
)
|
|
241
|
+
return self.root
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def parse(*, source: str) -> Document:
|
|
245
|
+
"""
|
|
246
|
+
Parse a JSONC document, retaining tokens, comments, and source
|
|
247
|
+
spans.
|
|
248
|
+
|
|
249
|
+
Duplicate keys and finite numbers outside binary64 range are rejected.
|
|
250
|
+
Up to 512 nested collections are accepted. No source is executed.
|
|
251
|
+
"""
|
|
252
|
+
tokens = tuple(tokenize(source=source))
|
|
253
|
+
syntax = tuple(
|
|
254
|
+
token
|
|
255
|
+
for token in tokens
|
|
256
|
+
if token.kind not in {TokenKind.COMMENT, TokenKind.WHITESPACE}
|
|
257
|
+
)
|
|
258
|
+
root = _Parser(source=source, tokens=syntax, frames=[], root=None).parse()
|
|
259
|
+
return Document(source=source, root=root, tokens=tokens)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def loads(*, source: str) -> Value:
|
|
263
|
+
"""Decode a JSONC string to ordinary Python values."""
|
|
264
|
+
return parse(source=source).value
|
jsonc_document/py.typed
ADDED
|
File without changes
|
jsonc_document/types.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Public recursive types for decoded JSONC values."""
|
|
2
|
+
|
|
3
|
+
type Scalar = str | int | float | bool | None
|
|
4
|
+
"""A decoded string, number, Boolean, or null value."""
|
|
5
|
+
|
|
6
|
+
type Value = Scalar | list[Value] | dict[str, Value]
|
|
7
|
+
"""A decoded scalar or recursively nested JSONC collection."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jsonc-document
|
|
3
|
+
Version: 2026.10.15
|
|
4
|
+
Summary: Typed JSONC parsing with preserved comments, tokens, and source spans.
|
|
5
|
+
Author-email: Adam Dangoor <adamdangoor@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Documentation, https://adamtheturtle.github.io/jsonc-document/
|
|
8
|
+
Project-URL: Source, https://github.com/adamtheturtle/jsonc-document
|
|
9
|
+
Keywords: comments,jsonc,parser
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Description-Content-Type: text/x-rst
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
jsonc-document
|
|
22
|
+
==============
|
|
23
|
+
|
|
24
|
+
A typed JSONC parser that preserves comments, tokens, and source spans.
|
|
25
|
+
It requires Python 3.12 or newer and has no runtime dependencies.
|
|
26
|
+
|
|
27
|
+
Installation
|
|
28
|
+
------------
|
|
29
|
+
|
|
30
|
+
.. code-block:: console
|
|
31
|
+
|
|
32
|
+
pip install jsonc-document
|
|
33
|
+
|
|
34
|
+
Usage
|
|
35
|
+
-----
|
|
36
|
+
|
|
37
|
+
.. code-block:: python
|
|
38
|
+
|
|
39
|
+
"""Parse JSONC while preserving source comments."""
|
|
40
|
+
|
|
41
|
+
import jsonc_document
|
|
42
|
+
|
|
43
|
+
source = '// heading\n{"count": 2}'
|
|
44
|
+
document = jsonc_document.parse(source=source)
|
|
45
|
+
assert document.value == {"count": 2}
|
|
46
|
+
assert document.comments[0].text == "// heading"
|
|
47
|
+
assert document.to_source() == source
|
|
48
|
+
|
|
49
|
+
Use ``loads(source=...)`` when only Python values are needed.
|
|
50
|
+
See the `documentation <https://adamtheturtle.github.io/jsonc-document/>`_ for the full API and parsing policies.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
jsonc_document/__init__.py,sha256=DQhCgABvaLCAjJcaX3ZvHE2iNGDJc2uB6I74W0gu_Yg,514
|
|
2
|
+
jsonc_document/_lexer.py,sha256=8rPMMLj3yfnzw2bzi5b7iCJet9FZF73awZfvRKTUevo,6411
|
|
3
|
+
jsonc_document/_model.py,sha256=vIGk2B95mgEi3hnkaXAe4evakQ-J0z7st6dDVbqUnrY,3888
|
|
4
|
+
jsonc_document/_parser.py,sha256=Q1wrrAqgeKlOJPXpZ22VUEBNFdPxbby9TxGHz07hiOc,8321
|
|
5
|
+
jsonc_document/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
jsonc_document/types.py,sha256=6AniLLgjI4U-4UEtXDyt1UoFd6hLGEkUzetHIqh42Xw,275
|
|
7
|
+
jsonc_document-2026.10.15.dist-info/licenses/LICENSE,sha256=cnfgEzv8YMudbgPysNfgAdqeOo4zf0y2C3Ctw4RgEE4,1069
|
|
8
|
+
jsonc_document-2026.10.15.dist-info/METADATA,sha256=jB_US4O61NSDzKBx7W_-mfWjGK9wq3uWHGuD3-3Lna4,1584
|
|
9
|
+
jsonc_document-2026.10.15.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
jsonc_document-2026.10.15.dist-info/top_level.txt,sha256=s1329lphqq1OrAM94ils1Sd5LRf6a72s9ZgjhFaiNSQ,15
|
|
11
|
+
jsonc_document-2026.10.15.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adam Dangoor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jsonc_document
|