hocon-parser 1.8.0__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.
hocon/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ """py.hocon — full Lightbend HOCON specification-compliant parser for Python.
2
+
3
+ Sibling of o3co's go.hocon / ts.hocon / rs.hocon, sharing the same 3-stage
4
+ pipeline (Lexer → Parser → Resolver) and the same conformance corpus
5
+ (o3co/xx.hocon). The public surface mirrors ts.hocon ``src/index.ts`` with
6
+ rs.hocon's snake_case accessor naming.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .config import Config, Period
12
+ from .errors import (
13
+ ConfigError,
14
+ NotResolvedError,
15
+ PackageLookupError,
16
+ ParseError,
17
+ ResolveError,
18
+ )
19
+ from .parse import parse, parse_file, parse_string
20
+ from .value import (
21
+ HoconArray,
22
+ HoconObject,
23
+ HoconScalar,
24
+ HoconValue,
25
+ ScalarValueType,
26
+ as_array,
27
+ as_boolean,
28
+ as_number,
29
+ as_object,
30
+ as_string,
31
+ is_array,
32
+ is_null,
33
+ is_object,
34
+ is_scalar,
35
+ )
36
+ from .value_factory import empty, from_map
37
+
38
+ __version__ = "0.0.0"
39
+
40
+ __all__ = [
41
+ "Config",
42
+ "ConfigError",
43
+ "HoconArray",
44
+ "HoconObject",
45
+ "HoconScalar",
46
+ "HoconValue",
47
+ "NotResolvedError",
48
+ "PackageLookupError",
49
+ "ParseError",
50
+ "Period",
51
+ "ResolveError",
52
+ "ScalarValueType",
53
+ "__version__",
54
+ "as_array",
55
+ "as_boolean",
56
+ "as_number",
57
+ "as_object",
58
+ "as_string",
59
+ "empty",
60
+ "from_map",
61
+ "is_array",
62
+ "is_null",
63
+ "is_object",
64
+ "is_scalar",
65
+ "parse",
66
+ "parse_file",
67
+ "parse_string",
68
+ ]
@@ -0,0 +1,4 @@
1
+ """Internal pipeline stages. Not part of the public API.
2
+
3
+ Mirrors ts.hocon ``src/internal/``: lexer / parser / resolver / properties.
4
+ """
@@ -0,0 +1 @@
1
+ """Stage 1 — Lexer. Mirrors ts.hocon ``src/internal/lexer/``."""
@@ -0,0 +1,500 @@
1
+ """Tokenizer. Mirrors ts.hocon ``src/internal/lexer/lexer.ts``.
2
+
3
+ Python ``str`` is pre-decoded Unicode at the I/O boundary (same as JS), so the
4
+ byte-level decisions in go.hocon / rs.hocon do not apply here; S1.1 is per-impl
5
+ out of scope for the same reason as ts.hocon.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ...errors import ParseError
11
+ from .token import Segment, SubstPayload, Token, TokenKind
12
+
13
+ __all__ = ["tokenize"]
14
+
15
+
16
+ def _is_hocon_whitespace(ch: str) -> bool:
17
+ """True if ``ch`` is a HOCON whitespace character per spec §Whitespace
18
+ (HOCON.md L165-184). Canonical single-source predicate; all lexer call sites
19
+ route through it.
20
+
21
+ NOTE: 0x0A (LF) is included here but ``_is_hocon_newline`` takes priority in
22
+ the main loop — the caller must check newline BEFORE whitespace. Do NOT use
23
+ ``str.isspace()`` (includes NEL 0x0085 which HOCON does not list). Hardcode
24
+ the set.
25
+ """
26
+ if ch == "":
27
+ return False
28
+ cp = ord(ch)
29
+ if cp in (0x09, 0x0A, 0x0B, 0x0C, 0x0D):
30
+ return True
31
+ if 0x1C <= cp <= 0x1F:
32
+ return True
33
+ if cp in (0x20, 0xA0, 0xFEFF):
34
+ return True
35
+ if cp == 0x1680:
36
+ return True
37
+ if 0x2000 <= cp <= 0x200A:
38
+ return True
39
+ if cp in (0x2028, 0x2029, 0x202F, 0x205F):
40
+ return True
41
+ if cp == 0x3000:
42
+ return True
43
+ return False
44
+
45
+
46
+ def _is_hocon_newline(ch: str) -> bool:
47
+ """True only for ASCII LF (0x0A), the sole HOCON newline character (HOCON.md
48
+ L182-184). Zl (0x2028) / Zp (0x2029) are whitespace, NOT newlines. Must be
49
+ checked BEFORE ``_is_hocon_whitespace`` in the main loop."""
50
+ return ch == "\n"
51
+
52
+
53
+ _SINGLE_CHAR_TOKENS: dict[str, TokenKind] = {
54
+ "{": "lbrace",
55
+ "}": "rbrace",
56
+ "[": "lbracket",
57
+ "]": "rbracket",
58
+ ",": "comma",
59
+ ":": "colon",
60
+ }
61
+
62
+
63
+ def _is_decimal_digit(ch: str) -> bool:
64
+ return "0" <= ch <= "9"
65
+
66
+
67
+ def _is_unquoted_subst_char(ch: str) -> bool:
68
+ """True if ``ch`` is a valid unquoted char inside a ``${...}`` body."""
69
+ if ch == "" or _is_hocon_whitespace(ch):
70
+ return False
71
+ if ch in '"\\':
72
+ return False
73
+ if ch in "{}[]":
74
+ return False
75
+ if ch in ":=,+#`^?!@*&$.":
76
+ return False
77
+ return True
78
+
79
+
80
+ def _is_unquoted_start(ch: str) -> bool:
81
+ if ch == "" or _is_hocon_whitespace(ch):
82
+ return False
83
+ if ch in '{}[],:=+#"$?!@*&^\\':
84
+ return False
85
+ return True
86
+
87
+
88
+ def _is_unquoted_continue(ch: str, nxt: str) -> bool:
89
+ if ch == "" or _is_hocon_whitespace(ch):
90
+ return False
91
+ if ch in '{}[],:=#"$?!@*&^\\':
92
+ return False
93
+ if ch == "+" and nxt == "=":
94
+ return False
95
+ if ch == "/" and nxt == "/":
96
+ return False
97
+ return True
98
+
99
+
100
+ class _Lexer:
101
+ def __init__(self, input_text: str) -> None:
102
+ if input_text and ord(input_text[0]) == 0xFEFF:
103
+ input_text = input_text[1:]
104
+ self.input = input_text
105
+ self.pos = 0
106
+ self.line = 1
107
+ self.col = 1
108
+ self.had_space = False
109
+ # Accumulates literal whitespace chars consumed between tokens. Reset on
110
+ # every token push. Comment text is NOT accumulated (E13).
111
+ self.whitespace_buffer = ""
112
+ self.tokens: list[Token] = []
113
+
114
+ def tokenize(self) -> list[Token]:
115
+ while self.pos < len(self.input):
116
+ sl, sc = self.line, self.col
117
+ ch = self._peek()
118
+
119
+ # Newline — checked before whitespace (whitespace returns True for LF).
120
+ if _is_hocon_newline(ch):
121
+ self._advance()
122
+ if not self.tokens or self.tokens[-1].kind != "newline":
123
+ self._push("newline", "\n", sl, sc)
124
+ continue
125
+
126
+ if _is_hocon_whitespace(ch):
127
+ self.whitespace_buffer += ch
128
+ self._advance()
129
+ self.had_space = True
130
+ continue
131
+
132
+ # Comments — set had_space but do NOT accumulate comment text (E13).
133
+ if ch == "/" and self._peek(1) == "/":
134
+ while self.pos < len(self.input) and self._peek() != "\n":
135
+ self._advance()
136
+ self.had_space = True
137
+ continue
138
+ if ch == "#":
139
+ while self.pos < len(self.input) and self._peek() != "\n":
140
+ self._advance()
141
+ self.had_space = True
142
+ continue
143
+
144
+ if ch in _SINGLE_CHAR_TOKENS:
145
+ self._advance()
146
+ self._push(_SINGLE_CHAR_TOKENS[ch], ch, sl, sc)
147
+ continue
148
+
149
+ if ch == "=":
150
+ self._advance()
151
+ self._push("equals", "=", sl, sc)
152
+ continue
153
+ if ch == "+" and self._peek(1) == "=":
154
+ self._advance()
155
+ self._advance()
156
+ self._push("plus_equals", "+=", sl, sc)
157
+ continue
158
+
159
+ # Substitution ${...} or ${?...}
160
+ if ch == "$" and self._peek(1) == "{":
161
+ self._advance()
162
+ self._advance() # consume '$' and '{'
163
+ payload = self._parse_subst_body(sl, sc)
164
+ # Reconstruct canonical value string from segments.
165
+ parts = []
166
+ for s in payload.segments:
167
+ t = s.text
168
+ if (
169
+ t == ""
170
+ or "." in t
171
+ or " " in t
172
+ or "\t" in t
173
+ or '"' in t
174
+ or "\\" in t
175
+ or t != t.strip()
176
+ ):
177
+ escaped = t.replace("\\", "\\\\").replace('"', '\\"')
178
+ parts.append(f'"{escaped}"')
179
+ else:
180
+ parts.append(t)
181
+ value = ".".join(parts)
182
+ self._push_subst(payload, value, sl, sc)
183
+ continue
184
+
185
+ # Triple-quoted string
186
+ if ch == '"' and self._peek(1) == '"' and self._peek(2) == '"':
187
+ self._advance()
188
+ self._advance()
189
+ self._advance()
190
+ value = ""
191
+ closed = False
192
+ while self.pos < len(self.input):
193
+ if self._peek() == '"':
194
+ quote_count = 0
195
+ while self.pos < len(self.input) and self._peek() == '"':
196
+ quote_count += 1
197
+ self._advance()
198
+ if quote_count >= 3:
199
+ value += '"' * (quote_count - 3)
200
+ closed = True
201
+ break
202
+ value += '"' * quote_count
203
+ continue
204
+ value += self._advance()
205
+ if not closed:
206
+ raise ParseError("unterminated triple-quoted string", sl, sc)
207
+ if value.startswith("\n"):
208
+ value = value[1:]
209
+ self._push("triple_string", value, sl, sc, True)
210
+ continue
211
+
212
+ # Quoted string
213
+ if ch == '"':
214
+ self._advance() # consume opening '"'
215
+ value = self._read_quoted_string_body(sl, sc)
216
+ self._push("string", value, sl, sc, True)
217
+ continue
218
+
219
+ # Unquoted string (stops at terminators and $). E8: leading '-' (not
220
+ # followed by a digit) is admitted here; digit-leading runs are also a
221
+ # single token; numeric coercion happens at the value layer.
222
+ if _is_unquoted_start(ch):
223
+ value = ""
224
+ while self.pos < len(self.input) and _is_unquoted_continue(
225
+ self._peek(), self._peek(1)
226
+ ):
227
+ value += self._advance()
228
+ # ts.hocon calls value.trimEnd() here, but it is a no-op: the run
229
+ # stops at any HOCON whitespace char, and JS's trimEnd set is a
230
+ # subset of HOCON_WS, so the value never ends with a char trimEnd
231
+ # would strip. Python's str.rstrip() strips a WIDER set (NEL etc.,
232
+ # which are valid unquoted chars here), so it must NOT be used.
233
+ self._push("unquoted", value, sl, sc)
234
+ continue
235
+
236
+ raise ParseError(f"unexpected character: {_json_char(ch)}", sl, sc)
237
+
238
+ self.tokens.append(Token("eof", "", self.line, self.col))
239
+ return self.tokens
240
+
241
+ def _peek(self, offset: int = 0) -> str:
242
+ idx = self.pos + offset
243
+ return self.input[idx] if 0 <= idx < len(self.input) else ""
244
+
245
+ def _advance(self) -> str:
246
+ ch = self.input[self.pos] if self.pos < len(self.input) else ""
247
+ self.pos += 1
248
+ if ch == "\n":
249
+ self.line += 1
250
+ self.col = 1
251
+ else:
252
+ self.col += 1
253
+ return ch
254
+
255
+ def _push(
256
+ self, kind: TokenKind, value: str, line: int, col: int, is_quoted: bool = False
257
+ ) -> None:
258
+ self.tokens.append(
259
+ Token(
260
+ kind,
261
+ value,
262
+ line,
263
+ col,
264
+ is_quoted,
265
+ self.had_space,
266
+ self.whitespace_buffer,
267
+ )
268
+ )
269
+ self.had_space = False
270
+ self.whitespace_buffer = ""
271
+
272
+ def _push_subst(self, payload: SubstPayload, value: str, line: int, col: int) -> None:
273
+ self.tokens.append(
274
+ Token(
275
+ "subst",
276
+ value,
277
+ line,
278
+ col,
279
+ False,
280
+ self.had_space,
281
+ self.whitespace_buffer,
282
+ payload,
283
+ )
284
+ )
285
+ self.had_space = False
286
+ self.whitespace_buffer = ""
287
+
288
+ def _read_quoted_string_body(self, open_line: int, open_col: int) -> str:
289
+ """Read body of a quoted string. Opening ``"`` already consumed."""
290
+ value = ""
291
+ while self.pos < len(self.input) and self._peek() != '"':
292
+ if self._peek() == "\n":
293
+ raise ParseError("unterminated string", open_line, open_col)
294
+ if self._peek() == "\\":
295
+ esc_col = self.col
296
+ self._advance() # consume '\'
297
+ if self.pos >= len(self.input):
298
+ raise ParseError("unterminated string", open_line, open_col)
299
+ esc = self._advance()
300
+ if esc == "n":
301
+ value += "\n"
302
+ elif esc == "t":
303
+ value += "\t"
304
+ elif esc == "r":
305
+ value += "\r"
306
+ elif esc == '"':
307
+ value += '"'
308
+ elif esc == "\\":
309
+ value += "\\"
310
+ elif esc == "/":
311
+ value += "/"
312
+ elif esc == "b":
313
+ value += "\b"
314
+ elif esc == "f":
315
+ value += "\f"
316
+ elif esc == "u":
317
+ if self.pos + 4 > len(self.input):
318
+ raise ParseError("invalid unicode escape", open_line, esc_col)
319
+ hex_digits = self.input[self.pos : self.pos + 4]
320
+ if not _is_hex4(hex_digits):
321
+ raise ParseError("invalid unicode escape", open_line, esc_col)
322
+ code = int(hex_digits, 16)
323
+ # Accept all \uXXXX escapes including surrogate code units
324
+ # (0xD800–0xDFFF): Python str, like JS/Java, can hold lone
325
+ # surrogates via surrogatepass. Intentional divergence from
326
+ # rs.hocon (Rust char cannot represent surrogates).
327
+ value += _from_char_code(code)
328
+ for _ in range(4):
329
+ self._advance()
330
+ else:
331
+ raise ParseError(f"unknown escape sequence: \\{esc}", open_line, esc_col)
332
+ else:
333
+ value += self._advance()
334
+ if self.pos >= len(self.input) or self._peek() != '"':
335
+ raise ParseError("unterminated string", open_line, open_col)
336
+ self._advance() # consume closing '"'
337
+ return value
338
+
339
+ def _parse_subst_body(self, start_line: int, start_col: int) -> SubstPayload:
340
+ """Parse the body of ``${...}`` (called after ``${`` consumed)."""
341
+ optional = False
342
+ if self._peek() == "?":
343
+ self._advance()
344
+ optional = True
345
+
346
+ cur_text = ""
347
+ cur_started = False
348
+ cur_line = 0
349
+ cur_col = 0
350
+
351
+ pending_ws = ""
352
+ segments: list[Segment] = []
353
+ last_dot: tuple[int, int] | None = None
354
+ list_suffix = False
355
+
356
+ while True:
357
+ if self.pos >= len(self.input):
358
+ raise ParseError("unterminated substitution", start_line, start_col)
359
+ ch = self._peek()
360
+
361
+ if ch == "}":
362
+ self._advance()
363
+ pending_ws = ""
364
+ break
365
+ elif ch == "[":
366
+ # S13c: `[]` suffix arm — fires at segment boundary.
367
+ if not cur_started:
368
+ raise ParseError(
369
+ "empty segment before '[]' suffix in substitution path",
370
+ start_line,
371
+ self.col,
372
+ )
373
+ # E7: only ASCII SPACE or TAB allowed between path and `[`.
374
+ for w in pending_ws:
375
+ if w != " " and w != "\t":
376
+ raise ParseError(
377
+ f"only ASCII space or tab allowed between substitution path "
378
+ f"and '[]' suffix (got {_json_char(w)}, HOCON extra-spec E7)",
379
+ start_line,
380
+ self.col,
381
+ )
382
+ segments.append(Segment(cur_text, cur_line, cur_col))
383
+ pending_ws = ""
384
+ list_suffix = self._parse_list_suffix(start_line)
385
+ break
386
+ elif ch == '"':
387
+ q_line = start_line # substitutions cannot span newlines
388
+ q_col = self.col
389
+ if cur_started:
390
+ cur_text += pending_ws
391
+ pending_ws = ""
392
+ self._advance() # consume opening '"'
393
+ decoded = self._read_quoted_string_body(q_line, q_col)
394
+ cur_text += decoded
395
+ if not cur_started:
396
+ cur_line = q_line
397
+ cur_col = q_col
398
+ cur_started = True
399
+ elif _is_unquoted_subst_char(ch):
400
+ # S8.6: a segment beginning with '-' must be followed by a digit.
401
+ if ch == "-" and not cur_started and not _is_decimal_digit(self._peek(1)):
402
+ after = "EOF" if self._peek(1) == "" else _json_char(self._peek(1))
403
+ raise ParseError(
404
+ f"unquoted path segment cannot begin with '-' unless followed by "
405
+ f"a digit (got '-' then {after}, HOCON.md L270-276)",
406
+ start_line,
407
+ self.col,
408
+ )
409
+ u_col = self.col
410
+ if cur_started:
411
+ cur_text += pending_ws
412
+ pending_ws = ""
413
+ if not cur_started:
414
+ cur_line = start_line
415
+ cur_col = u_col
416
+ cur_started = True
417
+ while self.pos < len(self.input) and _is_unquoted_subst_char(self._peek()):
418
+ cur_text += self._advance()
419
+ elif ch == ".":
420
+ dot_col = self.col
421
+ pending_ws = ""
422
+ if not cur_started:
423
+ raise ParseError("empty segment in path", start_line, dot_col)
424
+ segments.append(Segment(cur_text, cur_line, cur_col))
425
+ cur_text = ""
426
+ cur_started = False
427
+ cur_line = 0
428
+ cur_col = 0
429
+ last_dot = (start_line, dot_col)
430
+ self._advance()
431
+ elif _is_hocon_newline(ch):
432
+ raise ParseError("unterminated substitution", start_line, start_col)
433
+ elif _is_hocon_whitespace(ch):
434
+ # Non-newline HOCON whitespace: buffer into pending_ws. CR is
435
+ # whitespace, not newline — buffered here, not an error.
436
+ pending_ws += ch
437
+ self._advance()
438
+ else:
439
+ raise ParseError(
440
+ f"unexpected character in substitution path: {_json_char(ch)}",
441
+ start_line,
442
+ self.col,
443
+ )
444
+
445
+ if not list_suffix:
446
+ if cur_started:
447
+ segments.append(Segment(cur_text, cur_line, cur_col))
448
+ elif len(segments) == 0:
449
+ raise ParseError("empty substitution path", start_line, start_col)
450
+ else:
451
+ err_line, err_col = last_dot if last_dot is not None else (start_line, start_col)
452
+ raise ParseError("empty segment in path", err_line, err_col)
453
+
454
+ return SubstPayload(segments, optional, list_suffix)
455
+
456
+ def _parse_list_suffix(self, start_line: int) -> bool:
457
+ """Consume the literal ``[]`` suffix inside a substitution body."""
458
+ self._advance() # consume `[`
459
+ after_bracket = self._peek()
460
+ if after_bracket != "]":
461
+ desc = "EOF" if after_bracket == "" else _json_char(after_bracket)
462
+ raise ParseError(
463
+ f"expected ']' after '[' in substitution list suffix (got {desc})",
464
+ start_line,
465
+ self.col,
466
+ )
467
+ self._advance() # consume `]`
468
+ if self._peek() != "}":
469
+ desc = "EOF" if self._peek() == "" else _json_char(self._peek())
470
+ raise ParseError(
471
+ f"expected '}}' after '[]' in substitution list suffix (got {desc})",
472
+ start_line,
473
+ self.col,
474
+ )
475
+ self._advance() # consume `}`
476
+ return True
477
+
478
+
479
+ def _is_hex4(s: str) -> bool:
480
+ if len(s) != 4:
481
+ return False
482
+ return all(c in "0123456789abcdefABCDEF" for c in s)
483
+
484
+
485
+ def _from_char_code(code: int) -> str:
486
+ """Mirror JS ``String.fromCharCode``: build a char from a UTF-16 code unit,
487
+ allowing lone surrogates (Python keeps them via surrogatepass)."""
488
+ return chr(code)
489
+
490
+
491
+ def _json_char(ch: str) -> str:
492
+ """Render a single character the way ts.hocon's ``JSON.stringify(ch)`` does
493
+ in error messages: a double-quoted, escaped form."""
494
+ import json
495
+
496
+ return json.dumps(ch)
497
+
498
+
499
+ def tokenize(input_text: str) -> list[Token]:
500
+ return _Lexer(input_text).tokenize()
@@ -0,0 +1,65 @@
1
+ """Token and substitution-payload types. Mirrors ts.hocon ``src/internal/lexer/token.ts``.
2
+
3
+ ``TokenKind`` values match the ts.hocon string literals verbatim so the parser
4
+ port reads 1:1.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Literal
10
+
11
+ __all__ = ["Segment", "SubstPayload", "Token", "TokenKind"]
12
+
13
+ TokenKind = Literal[
14
+ "lbrace", "rbrace",
15
+ "lbracket", "rbracket",
16
+ "comma", "colon", "equals", "plus_equals",
17
+ "newline",
18
+ "string",
19
+ "triple_string",
20
+ "unquoted",
21
+ "subst",
22
+ "eof",
23
+ ]
24
+
25
+
26
+ class Segment:
27
+ def __init__(self, text: str, line: int, col: int) -> None:
28
+ self.text = text
29
+ self.line = line
30
+ self.col = col
31
+
32
+
33
+ class SubstPayload:
34
+ def __init__(self, segments: list[Segment], optional: bool, list_suffix: bool) -> None:
35
+ self.segments = segments
36
+ self.optional = optional
37
+ # True when the substitution body ends with '[]' (S13c).
38
+ self.list_suffix = list_suffix
39
+
40
+
41
+ class Token:
42
+ def __init__(
43
+ self,
44
+ kind: TokenKind,
45
+ value: str,
46
+ line: int,
47
+ col: int,
48
+ is_quoted: bool = False,
49
+ preceding_space: bool = False,
50
+ preceding_whitespace: str = "",
51
+ subst: SubstPayload | None = None,
52
+ ) -> None:
53
+ self.kind = kind
54
+ self.value = value
55
+ self.line = line
56
+ self.col = col
57
+ self.is_quoted = is_quoted
58
+ # True if preceded by whitespace OR a comment (concat detection: S10.5/S10.8).
59
+ self.preceding_space = preceding_space
60
+ # Literal preceding-whitespace chars since the previous token (path-WS
61
+ # preservation: E13). May be "" while preceding_space is True when the
62
+ # token is preceded only by a comment — the two answer different questions.
63
+ self.preceding_whitespace = preceding_whitespace
64
+ # Populated only when kind == 'subst'.
65
+ self.subst = subst
@@ -0,0 +1 @@
1
+ """Stage 2 — Parser. Mirrors ts.hocon ``src/internal/parser/``."""