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/config.py ADDED
@@ -0,0 +1,524 @@
1
+ """``Config`` — the resolved-configuration handle returned by the parse entry points.
2
+
3
+ Mirrors ts.hocon ``src/config.ts``. Accessor naming follows rs.hocon's
4
+ snake_case surface (``get_string`` / ``get_number`` / ``get_duration`` …), which
5
+ is already idiomatic Python. Deferred-resolution state (``resolve`` /
6
+ ``resolve_with`` / ``with_fallback``, NotResolvedError semantics) follows E12.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import re
13
+ from dataclasses import dataclass
14
+ from typing import Any, cast
15
+
16
+ from ._internal.resolver.resolver import (
17
+ build_partial_hocon_from_res_obj,
18
+ contains_placeholders,
19
+ hocon_value_to_res_obj,
20
+ resolve_tree,
21
+ val_contains_placeholders,
22
+ )
23
+ from ._internal.resolver.types import (
24
+ ConcatPlaceholder,
25
+ ResObj,
26
+ ResolveOptions,
27
+ SubstPlaceholder,
28
+ is_concat,
29
+ is_res_obj,
30
+ is_subst,
31
+ merge_unresolved,
32
+ )
33
+ from .coerce import (
34
+ ByteUnit,
35
+ DurationUnit,
36
+ coerce_boolean,
37
+ coerce_number,
38
+ parse_bytes,
39
+ parse_duration,
40
+ parse_period,
41
+ )
42
+ from .errors import ConfigError, NotResolvedError
43
+ from .numeric_array import numeric_object_to_array
44
+ from .value import HoconArray, HoconObject, HoconScalar, HoconValue, ScalarValueType
45
+
46
+ __all__ = ["Config", "Period"]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Period:
51
+ """A calendar period with year, month, and day components.
52
+
53
+ Returned by :meth:`Config.get_period`. All fields are plain ``int``s;
54
+ negative periods are permitted (matches Lightbend behaviour). New fields
55
+ (e.g. weeks, hours) may be added in a future minor version — mirrors
56
+ rs.hocon's ``#[non_exhaustive]`` ``Period`` struct.
57
+ """
58
+
59
+ years: int
60
+ months: int
61
+ days: int
62
+
63
+
64
+ class Config:
65
+ def __init__(
66
+ self,
67
+ root: HoconObject,
68
+ *,
69
+ resolved: bool = True,
70
+ parse_base_dir: str | None = None,
71
+ origin_description: str | None = None,
72
+ res_obj_root: ResObj | None = None,
73
+ resolve_opts: ResolveOptions | None = None,
74
+ ) -> None:
75
+ self._root = root
76
+ self._resolved = resolved
77
+ self._parse_base_dir = parse_base_dir
78
+ self._origin_description = origin_description
79
+ self._res_obj_root = res_obj_root
80
+ self._resolve_opts = resolve_opts
81
+
82
+ # ─── construction ─────────────────────────────────────────────────────────
83
+
84
+ def is_resolved(self) -> bool:
85
+ """True when the value tree contains no unresolved substitution
86
+ placeholders (whole-config granularity, E12 decision 11)."""
87
+ return self._resolved
88
+
89
+ @staticmethod
90
+ def _from_resolved_value(
91
+ root: HoconObject, origin_description: str | None = None
92
+ ) -> Config:
93
+ return Config(root, resolved=True, origin_description=origin_description)
94
+
95
+ @staticmethod
96
+ def _from_unresolved_res_obj(
97
+ tree: ResObj,
98
+ *,
99
+ parse_base_dir: str | None,
100
+ origin_description: str | None,
101
+ resolved: bool,
102
+ resolve_opts: ResolveOptions,
103
+ ) -> Config:
104
+ partial_root = build_partial_hocon_from_res_obj(tree)
105
+ return Config(
106
+ partial_root,
107
+ resolved=resolved,
108
+ parse_base_dir=parse_base_dir,
109
+ origin_description=origin_description,
110
+ res_obj_root=tree,
111
+ resolve_opts=resolve_opts,
112
+ )
113
+
114
+ # ─── accessors ────────────────────────────────────────────────────────────
115
+
116
+ def get(self, path: str) -> Any:
117
+ v = self._lookup_node(path)
118
+ if v is None:
119
+ return None
120
+ return _hocon_to_py(v)
121
+
122
+ def get_value(self, path: str) -> HoconValue | None:
123
+ """Raw HoconValue node at ``path`` for structural introspection, or None
124
+ if absent. Raises NotResolvedError when the node/subtree is unresolved."""
125
+ v = self._lookup_node(path)
126
+ if v is None:
127
+ if not self._resolved and self._subtree_has_placeholders(path):
128
+ raise NotResolvedError(path)
129
+ return None
130
+ if (
131
+ not isinstance(v, HoconScalar)
132
+ and not self._resolved
133
+ and self._subtree_has_placeholders(path)
134
+ ):
135
+ raise NotResolvedError(path)
136
+ return v
137
+
138
+ def get_string(self, path: str) -> str:
139
+ v = self._require_scalar(path)
140
+ # S17.6 (HOCON.md L1252): null → any non-null type is an error.
141
+ if v.value_type == "null":
142
+ raise ConfigError(f"expected string at {path}, got {v.value_type}", path)
143
+ return v.raw
144
+
145
+ def get_number(self, path: str) -> float | int:
146
+ v = self._require_scalar(path)
147
+ coerced = coerce_number(v.raw)
148
+ if coerced is not None:
149
+ return coerced
150
+ raise ConfigError(f"expected number at {path}, got {v.value_type}", path)
151
+
152
+ def get_int(self, path: str) -> int:
153
+ """Convenience: :meth:`get_number` truncated to an ``int``."""
154
+ return int(self.get_number(path))
155
+
156
+ def get_float(self, path: str) -> float:
157
+ """Convenience: :meth:`get_number` as a ``float``."""
158
+ return float(self.get_number(path))
159
+
160
+ def get_boolean(self, path: str) -> bool:
161
+ v = self._require_scalar(path)
162
+ coerced = coerce_boolean(v.raw)
163
+ if coerced is not None:
164
+ return coerced
165
+ raise ConfigError(f"expected boolean at {path}, got {v.value_type}", path)
166
+
167
+ def get_duration(self, path: str, unit: DurationUnit | None = None) -> float:
168
+ v = self._require_scalar(path)
169
+ if v.value_type != "string" and v.value_type != "number":
170
+ raise ConfigError(f"expected duration at {path}, got {v.value_type}", path)
171
+ result = parse_duration(v.raw, unit)
172
+ if math.isnan(result):
173
+ raise ConfigError(f"invalid duration at {path}: {v.raw!r}", path)
174
+ return result
175
+
176
+ def get_bytes(self, path: str, unit: ByteUnit | None = None) -> float:
177
+ v = self._require_scalar(path)
178
+ if v.value_type != "string" and v.value_type != "number":
179
+ raise ConfigError(f"expected byte size at {path}, got {v.value_type}", path)
180
+ result = parse_bytes(v.raw, unit)
181
+ if math.isnan(result):
182
+ raise ConfigError(f"invalid byte size at {path}: {v.raw!r}", path)
183
+ if result < 0:
184
+ raise ConfigError(f"byte size must be non-negative at {path}: {v.raw!r}", path)
185
+ return result
186
+
187
+ def get_period(self, path: str) -> Period:
188
+ """Calendar :class:`Period` at ``path`` (S20.1–S20.4). Accepts
189
+ ``"7d"`` / ``"2w"`` / ``"3m"`` / ``"1y"`` style strings or a bare
190
+ integer, which is taken as days (HOCON.md L1321). Period is
191
+ integer-only (Lightbend ``Integer.parseInt``): fractional values
192
+ raise, unlike :meth:`get_duration` / :meth:`get_bytes`."""
193
+ v = self._require_scalar(path)
194
+ if v.value_type != "string" and v.value_type != "number":
195
+ raise ConfigError(f"expected period at {path}, got {v.value_type}", path)
196
+ parsed = parse_period(v.raw)
197
+ if parsed is None:
198
+ raise ConfigError(f"invalid period at {path}: {v.raw!r}", path)
199
+ return Period(*parsed)
200
+
201
+ def get_config(self, path: str) -> Config:
202
+ v = self._lookup_node(path)
203
+ if v is None:
204
+ if not self._resolved and self._subtree_has_placeholders(path):
205
+ raise NotResolvedError(path)
206
+ raise ConfigError(f"path not found: {path}", path)
207
+ if not isinstance(v, HoconObject):
208
+ raise ConfigError(f"expected object at {path}", path)
209
+ if not self._resolved and self._subtree_has_placeholders(path):
210
+ raise NotResolvedError(path)
211
+ return Config(v, resolved=self._resolved)
212
+
213
+ def get_list(self, path: str) -> list[Any]:
214
+ v = self._lookup_node(path)
215
+ if v is None:
216
+ if not self._resolved and self._subtree_has_placeholders(path):
217
+ raise NotResolvedError(path)
218
+ raise ConfigError(f"path not found: {path}", path)
219
+ if not self._resolved and self._subtree_has_placeholders(path):
220
+ raise NotResolvedError(path)
221
+ # S15: numerically-keyed object → array before the type check.
222
+ if isinstance(v, HoconObject):
223
+ converted = numeric_object_to_array(v)
224
+ if converted is not None:
225
+ return [_hocon_to_py(x) for x in converted]
226
+ if not isinstance(v, HoconArray):
227
+ raise ConfigError(f"expected array at {path}", path)
228
+ return [_hocon_to_py(x) for x in v.items]
229
+
230
+ def has(self, path: str) -> bool:
231
+ return self._lookup_node(path) is not None
232
+
233
+ def keys(self) -> list[str]:
234
+ return list(self._root.fields.keys())
235
+
236
+ def with_fallback(self, fallback: Config | None) -> Config:
237
+ if fallback is None:
238
+ return self
239
+ self_tree = self._res_obj_root or hocon_value_to_res_obj(self._root)
240
+ fb_tree = fallback._res_obj_root or hocon_value_to_res_obj(fallback._root)
241
+ merged = merge_unresolved(self_tree, fb_tree)
242
+ has_placeholders = contains_placeholders(merged)
243
+ partial_root = build_partial_hocon_from_res_obj(merged)
244
+ return Config(
245
+ partial_root,
246
+ resolved=not has_placeholders,
247
+ parse_base_dir=self._parse_base_dir,
248
+ origin_description=self._origin_description,
249
+ res_obj_root=merged,
250
+ resolve_opts=self._resolve_opts or fallback._resolve_opts,
251
+ )
252
+
253
+ def resolve(
254
+ self, *, allow_unresolved: bool = False, use_system_environment: bool = True
255
+ ) -> Config:
256
+ """Run substitution resolution (phase 2) on the stored unresolved tree.
257
+ Idempotent on an already-resolved Config (E12 decision 3)."""
258
+ if self._resolved:
259
+ return Config(
260
+ self._root,
261
+ resolved=True,
262
+ parse_base_dir=self._parse_base_dir,
263
+ origin_description=self._origin_description,
264
+ )
265
+ tree = self._res_obj_root
266
+ if tree is None:
267
+ return Config(self._root, resolved=True)
268
+
269
+ resolve_opts = self._effective_resolve_opts(allow_unresolved, use_system_environment)
270
+ resolved = resolve_tree(tree, resolve_opts)
271
+ if not isinstance(resolved, HoconObject):
272
+ raise RuntimeError("resolve: expected object root")
273
+
274
+ if allow_unresolved:
275
+ stripped, had_placeholders = _strip_placeholder_fields(resolved)
276
+ return Config(
277
+ stripped,
278
+ resolved=not had_placeholders,
279
+ parse_base_dir=self._parse_base_dir,
280
+ origin_description=self._origin_description,
281
+ res_obj_root=tree if had_placeholders else None,
282
+ resolve_opts=resolve_opts if had_placeholders else None,
283
+ )
284
+
285
+ return Config(
286
+ resolved,
287
+ resolved=True,
288
+ parse_base_dir=self._parse_base_dir,
289
+ origin_description=self._origin_description,
290
+ )
291
+
292
+ def resolve_with(
293
+ self,
294
+ source: Config,
295
+ *,
296
+ allow_unresolved: bool = False,
297
+ use_system_environment: bool = True,
298
+ ) -> Config:
299
+ """Resolve receiver substitutions using ``source`` as lookup context;
300
+ source's keys are NOT merged into the result (E12 decisions 9, 10)."""
301
+ if not source.is_resolved():
302
+ raise NotResolvedError("source")
303
+ if self._resolved:
304
+ return Config(
305
+ self._root,
306
+ resolved=True,
307
+ parse_base_dir=self._parse_base_dir,
308
+ origin_description=self._origin_description,
309
+ )
310
+
311
+ receiver_tree = self._res_obj_root or hocon_value_to_res_obj(self._root)
312
+ src_tree = hocon_value_to_res_obj(source._root)
313
+ merged = merge_unresolved(receiver_tree, src_tree)
314
+
315
+ resolve_opts = self._effective_resolve_opts(allow_unresolved, use_system_environment)
316
+ resolved = resolve_tree(merged, resolve_opts)
317
+ if not isinstance(resolved, HoconObject):
318
+ raise RuntimeError("resolve_with: expected object root")
319
+
320
+ receiver_shape = _res_obj_to_key_shape(receiver_tree)
321
+ filtered = _filter_by_receiver_shape(resolved, receiver_shape)
322
+
323
+ if allow_unresolved:
324
+ stripped, had_placeholders = _strip_placeholder_fields(filtered)
325
+ return Config(
326
+ stripped,
327
+ resolved=not had_placeholders,
328
+ parse_base_dir=self._parse_base_dir,
329
+ origin_description=self._origin_description,
330
+ res_obj_root=receiver_tree if had_placeholders else None,
331
+ resolve_opts=resolve_opts if had_placeholders else None,
332
+ )
333
+
334
+ return Config(
335
+ filtered,
336
+ resolved=True,
337
+ parse_base_dir=self._parse_base_dir,
338
+ origin_description=self._origin_description,
339
+ )
340
+
341
+ def to_object(self) -> Any:
342
+ return _hocon_to_py(self._root)
343
+
344
+ def _render_json_for_test(self) -> str:
345
+ """Test-only: render this resolved Config as canonical JSON (sorted keys,
346
+ no whitespace). Used by the conformance harness. Raises on placeholders."""
347
+ return _render_hocon_as_json(self._root)
348
+
349
+ # ─── internals ────────────────────────────────────────────────────────────
350
+
351
+ def _effective_resolve_opts(
352
+ self, allow_unresolved: bool, use_system_environment: bool
353
+ ) -> ResolveOptions:
354
+ import dataclasses
355
+
356
+ base = self._resolve_opts or ResolveOptions()
357
+ return dataclasses.replace(
358
+ base,
359
+ allow_unresolved=allow_unresolved,
360
+ use_system_environment=use_system_environment,
361
+ origin_description=self._origin_description,
362
+ )
363
+
364
+ def _lookup_node(self, path: str) -> HoconValue | None:
365
+ segments = _split_config_path(path)
366
+ current: HoconValue = self._root
367
+ for seg in segments:
368
+ if not isinstance(current, HoconObject):
369
+ return None
370
+ nxt = current.fields.get(seg)
371
+ if nxt is None:
372
+ return None
373
+ current = nxt
374
+ return current
375
+
376
+ def _subtree_has_placeholders(self, path: str) -> bool:
377
+ tree = self._res_obj_root
378
+ if tree is None:
379
+ return False
380
+ segments = _split_config_path(path)
381
+ cur: ResObj = tree
382
+ for seg in segments:
383
+ val = cur.fields.get(seg)
384
+ if val is None:
385
+ return False
386
+ if is_res_obj(val):
387
+ cur = val
388
+ else:
389
+ return val_contains_placeholders(val)
390
+ return contains_placeholders(cur)
391
+
392
+ def _require_scalar(self, path: str) -> HoconScalar:
393
+ v = self._lookup_node(path)
394
+ if v is None:
395
+ if not self._resolved and self._subtree_has_placeholders(path):
396
+ raise NotResolvedError(path)
397
+ raise ConfigError(f"path not found: {path}", path)
398
+ if not isinstance(v, HoconScalar):
399
+ raise ConfigError(f"expected scalar at {path}, got {v.kind}", path)
400
+ return v
401
+
402
+
403
+ def _split_config_path(path: str) -> list[str]:
404
+ segments: list[str] = []
405
+ i = 0
406
+ while i < len(path):
407
+ if path[i] == '"':
408
+ i += 1
409
+ segment = ""
410
+ closed = False
411
+ while i < len(path):
412
+ ch = path[i]
413
+ if ch == "\\" and i + 1 < len(path):
414
+ segment += path[i + 1]
415
+ i += 2
416
+ continue
417
+ if ch == '"':
418
+ closed = True
419
+ i += 1
420
+ break
421
+ segment += ch
422
+ i += 1
423
+ if not closed:
424
+ raise ConfigError(f"unterminated quoted path segment: {path}", path)
425
+ segments.append(segment)
426
+ if i < len(path) and path[i] == ".":
427
+ i += 1
428
+ else:
429
+ dot = path.find(".", i)
430
+ if dot == -1:
431
+ segments.append(path[i:])
432
+ break
433
+ segments.append(path[i:dot])
434
+ i = dot + 1
435
+ return segments
436
+
437
+
438
+ def _scalar_to_py(raw: str, value_type: ScalarValueType) -> Any:
439
+ if value_type == "null":
440
+ return None
441
+ if value_type == "boolean":
442
+ return raw == "true"
443
+ if value_type == "number":
444
+ coerced = coerce_number(raw)
445
+ return coerced if coerced is not None else raw
446
+ return raw
447
+
448
+
449
+ def _hocon_to_py(v: HoconValue) -> Any:
450
+ if isinstance(v, HoconScalar):
451
+ return _scalar_to_py(v.raw, v.value_type)
452
+ if isinstance(v, HoconArray):
453
+ return [_hocon_to_py(item) for item in v.items]
454
+ return {k: _hocon_to_py(val) for k, val in v.fields.items()}
455
+
456
+
457
+ def _res_obj_to_key_shape(tree: ResObj) -> HoconObject:
458
+ fields: dict[str, HoconValue] = {}
459
+ for k, v in tree.fields.items():
460
+ if is_subst(v) or is_concat(v):
461
+ fields[k] = HoconScalar("", "null")
462
+ elif is_res_obj(v):
463
+ fields[k] = _res_obj_to_key_shape(v)
464
+ else:
465
+ # Neither placeholder nor ResObj → a plain HoconValue.
466
+ fields[k] = cast("HoconValue", v)
467
+ return HoconObject(fields)
468
+
469
+
470
+ def _strip_placeholder_fields(v: HoconObject) -> tuple[HoconObject, bool]:
471
+ fields: dict[str, HoconValue] = {}
472
+ had_placeholders = False
473
+ for k, val in v.fields.items():
474
+ if isinstance(val, (SubstPlaceholder, ConcatPlaceholder)):
475
+ had_placeholders = True
476
+ elif isinstance(val, HoconObject):
477
+ inner, inner_had = _strip_placeholder_fields(val)
478
+ if inner_had:
479
+ had_placeholders = True
480
+ fields[k] = inner
481
+ else:
482
+ fields[k] = val
483
+ return HoconObject(fields), had_placeholders
484
+
485
+
486
+ def _filter_by_receiver_shape(resolved: HoconObject, receiver_shape: HoconObject) -> HoconObject:
487
+ fields: dict[str, HoconValue] = {}
488
+ for k, rv in resolved.fields.items():
489
+ if k not in receiver_shape.fields:
490
+ continue
491
+ receiver_val = receiver_shape.fields[k]
492
+ if isinstance(rv, HoconObject) and isinstance(receiver_val, HoconObject):
493
+ fields[k] = _filter_by_receiver_shape(rv, receiver_val)
494
+ else:
495
+ fields[k] = rv
496
+ return HoconObject(fields)
497
+
498
+
499
+ # RFC 8259 JSON number grammar — gates the numeric render fast-path.
500
+ _JSON_NUMBER_RE = re.compile(r"-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?")
501
+ _LEADING_ZERO_RE = re.compile(r"^([+-]?)0+(\d)")
502
+
503
+
504
+ def _normalize_leading_zero_number(raw: str) -> str:
505
+ return _LEADING_ZERO_RE.sub(r"\1\2", raw)
506
+
507
+
508
+ def _render_hocon_as_json(v: HoconValue) -> str:
509
+ import json
510
+
511
+ if isinstance(v, HoconScalar):
512
+ if v.value_type == "null":
513
+ return "null"
514
+ if v.value_type == "boolean":
515
+ return v.raw
516
+ if v.value_type == "number":
517
+ norm = _normalize_leading_zero_number(v.raw)
518
+ return norm if _JSON_NUMBER_RE.fullmatch(norm) else json.dumps(v.raw)
519
+ return json.dumps(v.raw)
520
+ if isinstance(v, HoconArray):
521
+ return "[" + ",".join(_render_hocon_as_json(item) for item in v.items) + "]"
522
+ keys = sorted(v.fields.keys())
523
+ pairs = [f"{json.dumps(k)}:{_render_hocon_as_json(v.fields[k])}" for k in keys]
524
+ return "{" + ",".join(pairs) + "}"
hocon/errors.py ADDED
@@ -0,0 +1,71 @@
1
+ """Error types raised by the parser, resolver, and Config accessors.
2
+
3
+ Mirrors ts.hocon ``src/errors.ts``. The hierarchy is intentionally split in
4
+ two: ``ParseError`` / ``ResolveError`` belong to the parse/resolve pipeline,
5
+ while ``ConfigError`` covers accessor-time failures on a ``Config``. They do
6
+ not share a common base beyond ``Exception``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __all__ = [
12
+ "ConfigError",
13
+ "NotResolvedError",
14
+ "PackageLookupError",
15
+ "ParseError",
16
+ "ResolveError",
17
+ ]
18
+
19
+
20
+ class ParseError(Exception):
21
+ def __init__(self, message: str, line: int, col: int, file: str | None = None) -> None:
22
+ super().__init__(message)
23
+ self.line = line
24
+ self.col = col
25
+ self.file = file
26
+
27
+
28
+ class ResolveError(Exception):
29
+ def __init__(
30
+ self, message: str, path: str, line: int, col: int, file: str | None = None
31
+ ) -> None:
32
+ super().__init__(message)
33
+ self.path = path
34
+ self.line = line
35
+ self.col = col
36
+ self.file = file
37
+
38
+
39
+ class PackageLookupError(ResolveError):
40
+ """Raised when ``include package("id", "file")`` resolution fails because the
41
+ package cannot be located.
42
+
43
+ Extends ``ResolveError`` so callers who catch ``ResolveError`` still handle
44
+ it, while callers who need to distinguish a missing-package error can use
45
+ ``isinstance(err, PackageLookupError)``.
46
+ """
47
+
48
+ def __init__(
49
+ self, message: str, identifier: str, package_file: str, line: int, col: int
50
+ ) -> None:
51
+ super().__init__(message, f"{identifier}/{package_file}", line, col)
52
+ self.identifier = identifier
53
+ self.package_file = package_file
54
+
55
+
56
+ class ConfigError(Exception):
57
+ def __init__(self, message: str, path: str) -> None:
58
+ super().__init__(message)
59
+ self.path = path
60
+
61
+
62
+ class NotResolvedError(ConfigError):
63
+ """Raised when a getter is called on a Config whose value (or any transitive
64
+ parent) contains an unresolved substitution placeholder. E12 decision 12.
65
+
66
+ Detect with ``isinstance(err, NotResolvedError)``; it also passes
67
+ ``isinstance(err, ConfigError)``.
68
+ """
69
+
70
+ def __init__(self, path: str) -> None:
71
+ super().__init__(f'value at path "{path}" is not resolved (call resolve() first)', path)
hocon/numeric_array.py ADDED
@@ -0,0 +1,51 @@
1
+ """S15 — numerically-indexed object → array conversion helper.
2
+
3
+ Mirrors ts.hocon ``src/value/numeric-array.ts``. Private helper shared between
4
+ the accessor call site (``Config.get_list``) and the resolver concat call site
5
+ (``SubstitutionResolver._resolve_concat``); not part of the public API.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from .value import HoconObject, HoconValue
13
+
14
+ __all__ = ["numeric_object_to_array"]
15
+
16
+ # Canonical decimal non-negative integers only.
17
+ # Rejects: "+1", "-0", "-1", "00", "01", " 1", "1 ", "0x1", "1e2", "1.0", "".
18
+ _CANONICAL_INT = re.compile(r"(?:0|[1-9][0-9]*)")
19
+
20
+
21
+ def numeric_object_to_array(value: HoconValue) -> list[HoconValue] | None:
22
+ """Attempt to convert a numerically-keyed HOCON object to an array.
23
+
24
+ - not an object → None
25
+ - empty object → None (S15.4: empty NOT converted)
26
+ - no key parses as non-neg int → None
27
+ - otherwise → values sorted by parsed key (ascending)
28
+
29
+ Conversion is non-recursive: only top-level keys are examined. A new list is
30
+ returned; the original object is not mutated.
31
+ """
32
+ if not isinstance(value, HoconObject):
33
+ return None
34
+ if len(value.fields) == 0:
35
+ return None
36
+
37
+ eligible: list[tuple[int, HoconValue]] = []
38
+ for key, val in value.fields.items():
39
+ if not _CANONICAL_INT.fullmatch(key):
40
+ continue
41
+ n = int(key)
42
+ # i32 max: 2147483647
43
+ if n > 2_147_483_647:
44
+ continue
45
+ eligible.append((n, val))
46
+
47
+ if not eligible:
48
+ return None
49
+
50
+ eligible.sort(key=lambda e: e[0])
51
+ return [e[1] for e in eligible]