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.
@@ -0,0 +1,84 @@
1
+ """Java-properties parsing for ``.properties`` includes.
2
+
3
+ Mirrors ts.hocon ``src/internal/properties/properties.ts``. Multi-line + Unicode
4
+ escapes are a documented simplification (S23.5, S23.6). Object always wins over
5
+ scalar on a key conflict (S23.4, HOCON.md L1485), enforced via key-sorted
6
+ insertion so conflict direction is input-order independent.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from ...value import HoconObject, HoconScalar, HoconValue
14
+
15
+ __all__ = ["parse_properties", "properties_to_hocon_value"]
16
+
17
+ _DANGEROUS_KEYS = frozenset(("__proto__", "constructor", "prototype"))
18
+
19
+
20
+ def parse_properties(input_text: str) -> dict[str, Any]:
21
+ root: dict[str, Any] = {}
22
+
23
+ pairs: list[tuple[str, str]] = []
24
+ for line in input_text.split("\n"):
25
+ trimmed = line.strip()
26
+ if trimmed == "" or trimmed.startswith("#") or trimmed.startswith("!"):
27
+ continue
28
+ sep_idx = _find_separator(trimmed)
29
+ if sep_idx == -1:
30
+ continue
31
+ key = trimmed[:sep_idx].strip()
32
+ value = trimmed[sep_idx + 1 :].strip()
33
+ if key == "":
34
+ continue
35
+ pairs.append((key, value))
36
+
37
+ # Sort by key so conflict-direction is input-order independent (S23.4).
38
+ pairs.sort(key=lambda kv: kv[0])
39
+
40
+ for key, value in pairs:
41
+ _set_nested(root, key.split("."), value)
42
+
43
+ return root
44
+
45
+
46
+ def _find_separator(line: str) -> int:
47
+ for i, ch in enumerate(line):
48
+ if ch == "=" or ch == ":":
49
+ return i
50
+ return -1
51
+
52
+
53
+ def _set_nested(obj: dict[str, Any], segments: list[str], value: str) -> None:
54
+ current = obj
55
+ for seg in segments[:-1]:
56
+ if seg in _DANGEROUS_KEYS:
57
+ return
58
+ existing = current.get(seg)
59
+ if not isinstance(existing, dict):
60
+ current[seg] = {}
61
+ current = current[seg]
62
+ last = segments[-1]
63
+ if last in _DANGEROUS_KEYS:
64
+ return
65
+ # S23.4 — object always wins over scalar: do not overwrite an object.
66
+ if isinstance(current.get(last), dict):
67
+ return
68
+ current[last] = value
69
+
70
+
71
+ def properties_to_hocon_value(input_text: str) -> HoconValue:
72
+ """Convert a ``.properties`` string to a HoconValue (object with string
73
+ scalars). All values remain strings — no type coercion."""
74
+ return _record_to_hocon_value(parse_properties(input_text))
75
+
76
+
77
+ def _record_to_hocon_value(obj: dict[str, Any]) -> HoconObject:
78
+ fields: dict[str, HoconValue] = {}
79
+ for key, val in obj.items():
80
+ if isinstance(val, str):
81
+ fields[key] = HoconScalar(val, "string")
82
+ elif isinstance(val, dict):
83
+ fields[key] = _record_to_hocon_value(val)
84
+ return HoconObject(fields)
@@ -0,0 +1 @@
1
+ """Stage 3 — Resolver. Mirrors ts.hocon ``src/internal/resolver/``."""
@@ -0,0 +1,282 @@
1
+ """Helpers for chained / value-interior self-referential-substitution support.
2
+
3
+ Mirrors ts.hocon ``src/internal/resolver/fold-self-ref.ts`` (itself a port of
4
+ go.hocon ``foldselfref.go`` and rs.hocon ``fold_self_ref.rs``). Folds
5
+ occurrences of ``${key}`` inside a value about to be saved as ``prior_values[key]``
6
+ against the OLD prior, so by induction every saved prior is self-ref-free — the
7
+ fix for the N≥3 self-append chain overflow (#118 / #120).
8
+
9
+ For the ts-known-violation item S13a.3, the reference behaviour is rs.hocon's;
10
+ this port follows ts.hocon structurally, matching rs where they agree.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import cast
17
+
18
+ from ...value import HoconArray, HoconObject, HoconValue
19
+ from ..lexer.token import Segment
20
+ from .types import (
21
+ ConcatPlaceholder,
22
+ ResObj,
23
+ ResolverValue,
24
+ SubstPlaceholder,
25
+ is_concat,
26
+ is_res_obj,
27
+ is_subst,
28
+ )
29
+
30
+ __all__ = [
31
+ "clone_resolver_value",
32
+ "contains_self_ref",
33
+ "contains_subst_by_path",
34
+ "fold_known_absent_self_ref",
35
+ "fold_nested_self_refs",
36
+ "fold_or_skip_prior",
37
+ "fold_self_ref",
38
+ "string_segments_to_key",
39
+ "subst_full_key",
40
+ ]
41
+
42
+ _NON_BARE_KEY = re.compile(r"[^a-zA-Z0-9\-_]")
43
+
44
+
45
+ def _hv_list(items: list[ResolverValue]) -> list[HoconValue]:
46
+ # Resolver containers transiently hold placeholders during folding; ts.hocon
47
+ # casts these ``as HoconValue``. The public array/object contract remains
48
+ # HoconValue-only, so the cast is contained to these builder sites.
49
+ return cast("list[HoconValue]", items)
50
+
51
+
52
+ def _hv_dict(fields: dict[str, ResolverValue]) -> dict[str, HoconValue]:
53
+ return cast("dict[str, HoconValue]", fields)
54
+
55
+
56
+ def string_segments_to_key(segments: list[str]) -> str:
57
+ parts = []
58
+ for t in segments:
59
+ if t == "" or _NON_BARE_KEY.search(t):
60
+ parts.append('"' + t.replace("\\", "\\\\").replace('"', '\\"') + '"')
61
+ else:
62
+ parts.append(t)
63
+ return ".".join(parts)
64
+
65
+
66
+ def subst_full_key(s: SubstPlaceholder) -> str:
67
+ return string_segments_to_key([seg.text for seg in s.segments])
68
+
69
+
70
+ def contains_self_ref(v: ResolverValue, full_key: str) -> bool:
71
+ if is_subst(v):
72
+ return not v.known_absent and subst_full_key(v) == full_key
73
+ if is_concat(v):
74
+ return any(contains_self_ref(n, full_key) for n in v.nodes)
75
+ if is_res_obj(v):
76
+ return any(contains_self_ref(f, full_key) for f in v.fields.values())
77
+ if isinstance(v, HoconArray):
78
+ return any(contains_self_ref(item, full_key) for item in v.items)
79
+ if isinstance(v, HoconObject):
80
+ return any(contains_self_ref(f, full_key) for f in v.fields.values())
81
+ return False
82
+
83
+
84
+ def fold_self_ref(
85
+ v: ResolverValue, full_key: str, replacement: ResolverValue
86
+ ) -> ResolverValue:
87
+ if is_subst(v):
88
+ return replacement if subst_full_key(v) == full_key else v
89
+ if is_concat(v):
90
+ return ConcatPlaceholder(
91
+ [fold_self_ref(n, full_key, replacement) for n in v.nodes], v.line, v.col
92
+ )
93
+ if is_res_obj(v):
94
+ out = ResObj()
95
+ for k, val in v.fields.items():
96
+ out.fields[k] = fold_self_ref(val, full_key, replacement)
97
+ out.prior_values = dict(v.prior_values)
98
+ out.reset_keys = set(v.reset_keys)
99
+ return out
100
+ if isinstance(v, HoconArray):
101
+ return HoconArray(
102
+ _hv_list([fold_self_ref(item, full_key, replacement) for item in v.items])
103
+ )
104
+ if isinstance(v, HoconObject):
105
+ return HoconObject(
106
+ _hv_dict({k: fold_self_ref(val, full_key, replacement) for k, val in v.fields.items()})
107
+ )
108
+ return v
109
+
110
+
111
+ def fold_known_absent_self_ref(
112
+ v: ResolverValue, full_key: str, replacement: ResolverValue
113
+ ) -> ResolverValue:
114
+ if is_subst(v):
115
+ return replacement if (v.known_absent and subst_full_key(v) == full_key) else v
116
+ if is_concat(v):
117
+ return ConcatPlaceholder(
118
+ [fold_known_absent_self_ref(n, full_key, replacement) for n in v.nodes],
119
+ v.line,
120
+ v.col,
121
+ )
122
+ if is_res_obj(v):
123
+ out = ResObj()
124
+ for k, val in v.fields.items():
125
+ out.fields[k] = fold_known_absent_self_ref(val, full_key, replacement)
126
+ out.prior_values = dict(v.prior_values)
127
+ out.reset_keys = set(v.reset_keys)
128
+ return out
129
+ if isinstance(v, HoconArray):
130
+ return HoconArray(
131
+ _hv_list(
132
+ [fold_known_absent_self_ref(item, full_key, replacement) for item in v.items]
133
+ )
134
+ )
135
+ if isinstance(v, HoconObject):
136
+ return HoconObject(
137
+ _hv_dict(
138
+ {
139
+ k: fold_known_absent_self_ref(val, full_key, replacement)
140
+ for k, val in v.fields.items()
141
+ }
142
+ )
143
+ )
144
+ return v
145
+
146
+
147
+ def fold_or_skip_prior(
148
+ prior: ResolverValue, full_key: str, old: ResolverValue | None
149
+ ) -> ResolverValue | None:
150
+ """Three-way decision at a prior-save site (see ts.hocon fold-self-ref.ts)."""
151
+ if not contains_self_ref(prior, full_key):
152
+ return clone_resolver_value(prior)
153
+ if old is None:
154
+ return _fold_optional_self_ref_absent(prior, full_key)
155
+ return fold_self_ref(prior, full_key, old)
156
+
157
+
158
+ def _fold_optional_self_ref_absent(
159
+ v: ResolverValue, full_key: str
160
+ ) -> ResolverValue | None:
161
+ if is_subst(v) and subst_full_key(v) == full_key:
162
+ if not v.optional:
163
+ return None
164
+ return SubstPlaceholder(
165
+ [Segment(seg.text, seg.line, seg.col) for seg in v.segments],
166
+ v.optional,
167
+ True,
168
+ v.list_suffix,
169
+ v.line,
170
+ v.col,
171
+ v.prefix_len,
172
+ )
173
+ if is_concat(v):
174
+ nodes: list[ResolverValue] = []
175
+ for node in v.nodes:
176
+ folded = _fold_optional_self_ref_absent(node, full_key)
177
+ if folded is None:
178
+ return None
179
+ nodes.append(folded)
180
+ return ConcatPlaceholder(nodes, v.line, v.col)
181
+ if is_res_obj(v):
182
+ out = ResObj()
183
+ for key, value in v.fields.items():
184
+ folded = _fold_optional_self_ref_absent(value, full_key)
185
+ if folded is None:
186
+ return None
187
+ out.fields[key] = folded
188
+ out.prior_values = dict(v.prior_values)
189
+ out.reset_keys = set(v.reset_keys)
190
+ return out
191
+ if isinstance(v, HoconArray):
192
+ items: list[ResolverValue] = []
193
+ for item in v.items:
194
+ folded = _fold_optional_self_ref_absent(item, full_key)
195
+ if folded is None:
196
+ return None
197
+ items.append(folded)
198
+ return HoconArray(_hv_list(items))
199
+ if isinstance(v, HoconObject):
200
+ obj_fields: dict[str, ResolverValue] = {}
201
+ for key, value in v.fields.items():
202
+ folded = _fold_optional_self_ref_absent(value, full_key)
203
+ if folded is None:
204
+ return None
205
+ obj_fields[key] = folded
206
+ return HoconObject(_hv_dict(obj_fields))
207
+ return clone_resolver_value(v)
208
+
209
+
210
+ def clone_resolver_value(v: ResolverValue) -> ResolverValue:
211
+ """Deep-clone a ResolverValue. Scalars share their reference (immutable and
212
+ identity-observable via ``separator_values``)."""
213
+ if is_subst(v):
214
+ return SubstPlaceholder(
215
+ [Segment(seg.text, seg.line, seg.col) for seg in v.segments],
216
+ v.optional,
217
+ v.known_absent,
218
+ v.list_suffix,
219
+ v.line,
220
+ v.col,
221
+ v.prefix_len,
222
+ )
223
+ if is_concat(v):
224
+ return ConcatPlaceholder([clone_resolver_value(n) for n in v.nodes], v.line, v.col)
225
+ if is_res_obj(v):
226
+ out = ResObj()
227
+ for k, val in v.fields.items():
228
+ out.fields[k] = clone_resolver_value(val)
229
+ for k, val in v.prior_values.items():
230
+ out.prior_values[k] = clone_resolver_value(val)
231
+ out.reset_keys = set(v.reset_keys)
232
+ return out
233
+ if isinstance(v, HoconArray):
234
+ return HoconArray(_hv_list([clone_resolver_value(item) for item in v.items]))
235
+ if isinstance(v, HoconObject):
236
+ return HoconObject(
237
+ _hv_dict({k: clone_resolver_value(val) for k, val in v.fields.items()})
238
+ )
239
+ return v
240
+
241
+
242
+ def fold_nested_self_refs(v: ResolverValue, path_prefix: list[str]) -> ResolverValue:
243
+ """Recursively fold nested self-refs inside a ResObj tree using each enclosing
244
+ ResObj's ``prior_values`` as the substitution target (multi-segment
245
+ object-merge case, #120-class)."""
246
+ if not is_res_obj(v):
247
+ return v
248
+ out = ResObj()
249
+ for k, field_val in v.fields.items():
250
+ child_path = [*path_prefix, k]
251
+ full_key = string_segments_to_key(child_path)
252
+ folded = fold_nested_self_refs(field_val, child_path)
253
+ final_val = folded
254
+ if contains_self_ref(folded, full_key):
255
+ leaf_prior = v.prior_values.get(k)
256
+ if leaf_prior is not None:
257
+ leaf_prior_folded = fold_nested_self_refs(leaf_prior, child_path)
258
+ final_val = fold_self_ref(folded, full_key, leaf_prior_folded)
259
+ out.fields[k] = final_val
260
+ out.prior_values = dict(v.prior_values)
261
+ out.reset_keys = set(v.reset_keys)
262
+ return out
263
+
264
+
265
+ def contains_subst_by_path(v: ResolverValue, target: list[Segment]) -> bool:
266
+ if is_subst(v):
267
+ return not v.known_absent and _segments_text_equal(v.segments, target)
268
+ if is_concat(v):
269
+ return any(contains_subst_by_path(n, target) for n in v.nodes)
270
+ if is_res_obj(v):
271
+ return any(contains_subst_by_path(f, target) for f in v.fields.values())
272
+ if isinstance(v, HoconArray):
273
+ return any(contains_subst_by_path(item, target) for item in v.items)
274
+ if isinstance(v, HoconObject):
275
+ return any(contains_subst_by_path(f, target) for f in v.fields.values())
276
+ return False
277
+
278
+
279
+ def _segments_text_equal(a: list[Segment], b: list[Segment]) -> bool:
280
+ if len(a) != len(b):
281
+ return False
282
+ return all(a[i].text == b[i].text for i in range(len(a)))
@@ -0,0 +1,252 @@
1
+ """Include loading (file / package). Mirrors ts.hocon ``src/internal/resolver/include-loader.ts``.
2
+
3
+ URL and classpath includes are unsupported by design across all siblings
4
+ (S14a.2 etc.). Node's ``require.resolve`` has no Python analogue, so
5
+ ``include package("id", "file")`` resolves via a filesystem convention (search
6
+ ``resolve_from`` / ``base_dir`` / CWD for ``<base>/id/file``) or a caller-supplied
7
+ ``package_resolver`` hook. Sync path only (py.hocon has no async parse variant).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import dataclasses
13
+ import os
14
+ import re
15
+ from collections.abc import Callable
16
+
17
+ from ...errors import PackageLookupError, ParseError, ResolveError
18
+ from ..lexer.lexer import tokenize
19
+ from ..parser.ast import AstNode
20
+ from ..parser.empty_check import assert_non_empty_document, has_content_tokens
21
+ from ..parser.parser import parse_tokens
22
+ from ..properties.properties import properties_to_hocon_value
23
+ from .types import PackageResolver, ResObj, ResolveOptions, make_res_obj
24
+ from .utils import (
25
+ deep_merge_res_obj_into,
26
+ hocon_value_to_res_obj,
27
+ is_file_not_found_error,
28
+ )
29
+
30
+ __all__ = ["IncludeLoader"]
31
+
32
+
33
+ def _validate_package_file(file: str, identifier: str) -> None:
34
+ """Validate the file argument of ``package("id", "file")`` per E11 decision 6."""
35
+ if len(file) == 0:
36
+ raise ParseError(
37
+ f'include package("{identifier}", ...): file argument must be non-empty', 0, 0
38
+ )
39
+ if "\\" in file:
40
+ raise ParseError(
41
+ f'include package("{identifier}", "{file}"): backslash not allowed in file '
42
+ f"argument (use forward slash)",
43
+ 0,
44
+ 0,
45
+ )
46
+ if file.startswith("/"):
47
+ raise ParseError(
48
+ f'include package("{identifier}", "{file}"): absolute path not allowed '
49
+ f"in file argument",
50
+ 0,
51
+ 0,
52
+ )
53
+ if re.search(r"(?:^|/)\.\.?(?:/|$)", file):
54
+ raise ParseError(
55
+ f'include package("{identifier}", "{file}"): path traversal (. or ..) not '
56
+ f"allowed in file argument",
57
+ 0,
58
+ 0,
59
+ )
60
+ if "//" in file:
61
+ raise ParseError(
62
+ f'include package("{identifier}", "{file}"): consecutive slashes not allowed '
63
+ f"in file argument",
64
+ 0,
65
+ 0,
66
+ )
67
+
68
+
69
+ def _validate_package_identifier(identifier: str) -> None:
70
+ if len(identifier) == 0:
71
+ raise ParseError("include package: identifier argument must be non-empty", 0, 0)
72
+
73
+
74
+ def _default_package_resolver(
75
+ identifier: str,
76
+ file: str,
77
+ including_file: str | None,
78
+ base_dir: str | None,
79
+ resolve_from: object,
80
+ ) -> str:
81
+ """Filesystem-convention resolver: search candidate bases for ``id/file``.
82
+
83
+ Node's module resolution is not available in Python, so this looks for
84
+ ``<base>/<identifier>/<file>`` under, in priority order: ``resolve_from`` >
85
+ ``base_dir`` > dir of ``including_file`` > CWD. Raises PackageLookupError on
86
+ miss. Callers needing package-manager semantics pass a custom resolver.
87
+ """
88
+ if resolve_from:
89
+ bases = (
90
+ list(resolve_from)
91
+ if isinstance(resolve_from, (list, tuple))
92
+ else [str(resolve_from)]
93
+ )
94
+ elif base_dir:
95
+ bases = [base_dir]
96
+ elif including_file:
97
+ bases = [os.path.dirname(including_file)]
98
+ else:
99
+ bases = [os.getcwd()]
100
+
101
+ for base in bases:
102
+ candidate = os.path.join(base, identifier, file)
103
+ if os.path.isfile(candidate):
104
+ # E11 decision 5: case-sensitive basename check.
105
+ expected_basename = file.split("/")[-1]
106
+ if os.path.basename(candidate) != expected_basename:
107
+ raise PackageLookupError(
108
+ f'include package("{identifier}", "{file}"): case mismatch',
109
+ identifier,
110
+ file,
111
+ 0,
112
+ 0,
113
+ )
114
+ return candidate
115
+
116
+ raise PackageLookupError(
117
+ f'include package("{identifier}", "{file}"): module not found '
118
+ f"(searched: {', '.join(bases)})",
119
+ identifier,
120
+ file,
121
+ 0,
122
+ 0,
123
+ )
124
+
125
+
126
+ def _unset_build(_ast: AstNode, _opts: ResolveOptions) -> ResObj: # pragma: no cover
127
+ raise RuntimeError("IncludeLoader.on_build_res_obj was not set by StructureBuilder")
128
+
129
+
130
+ class IncludeLoader:
131
+ def __init__(self, opts: ResolveOptions) -> None:
132
+ self.opts = opts
133
+ # Set by StructureBuilder to delegate back to build() (avoids a cycle).
134
+ self.on_build_res_obj: Callable[[AstNode, ResolveOptions], ResObj] = _unset_build
135
+
136
+ def _resolve_include_path(
137
+ self, include_path: str, base_dir: str | None, is_file: bool
138
+ ) -> str:
139
+ if is_file:
140
+ return os.path.abspath(include_path)
141
+ if base_dir:
142
+ return os.path.abspath(os.path.join(base_dir, include_path))
143
+ return os.path.abspath(include_path)
144
+
145
+ def load(self, include_path: str, required: bool, is_file: bool = False) -> ResObj:
146
+ base_dir = self.opts.base_dir
147
+ include_stack = self.opts.include_stack
148
+ abs_path = self._resolve_include_path(include_path, base_dir, is_file)
149
+
150
+ if abs_path in include_stack:
151
+ raise ResolveError(f"circular include: {abs_path}", abs_path, 0, 0)
152
+ if len(include_stack) >= 50:
153
+ raise ResolveError("include depth limit exceeded (max 50)", include_path, 0, 0)
154
+
155
+ has_explicit_ext = abs_path.endswith((".conf", ".json", ".properties"))
156
+
157
+ if has_explicit_ext:
158
+ result = self._load_single(abs_path)
159
+ if result is not None:
160
+ return result
161
+ if required:
162
+ raise ResolveError(
163
+ f"required include file not found: {include_path}", include_path, 0, 0
164
+ )
165
+ return make_res_obj()
166
+
167
+ # No extension: merge all found extensions (.properties, .json, .conf; last wins).
168
+ merged = make_res_obj()
169
+ found_any = False
170
+ for ext in (".properties", ".json", ".conf"):
171
+ obj = self._load_single(f"{abs_path}{ext}")
172
+ if obj is not None:
173
+ deep_merge_res_obj_into(merged, obj, [])
174
+ found_any = True
175
+
176
+ if not found_any and required:
177
+ raise ResolveError(
178
+ f"required include file not found: {include_path}", include_path, 0, 0
179
+ )
180
+ return merged
181
+
182
+ def load_package(self, identifier: str, file: str, _required: bool) -> ResObj:
183
+ _validate_package_identifier(identifier)
184
+ _validate_package_file(file, identifier)
185
+
186
+ include_stack = self.opts.include_stack
187
+ cycle_key = f'package\x00{identifier}\x00{file}'
188
+
189
+ if cycle_key in include_stack:
190
+ raise ResolveError(
191
+ f'circular include: package("{identifier}", "{file}")',
192
+ f"{identifier}/{file}",
193
+ 0,
194
+ 0,
195
+ )
196
+ if len(include_stack) >= 50:
197
+ raise ResolveError(
198
+ "include depth limit exceeded (max 50)", f"{identifier}/{file}", 0, 0
199
+ )
200
+
201
+ resolver: PackageResolver = self.opts.package_resolver or (
202
+ lambda id_, f, including_file, b_dir: _default_package_resolver(
203
+ id_, f, including_file, b_dir, self.opts.resolve_from
204
+ )
205
+ )
206
+ resolved_path = resolver(identifier, file, None, self.opts.base_dir)
207
+
208
+ content = self.opts.read_file_sync(resolved_path)
209
+ # Empty content (zero bytes) is valid for package includes (ipk08) → {}.
210
+ if len(content) == 0:
211
+ return make_res_obj()
212
+
213
+ tokens = tokenize(content)
214
+ assert_non_empty_document(tokens, resolved_path)
215
+ ast = parse_tokens(tokens)
216
+ return self.on_build_res_obj(
217
+ ast,
218
+ dataclasses.replace(
219
+ self.opts,
220
+ base_dir=os.path.dirname(resolved_path),
221
+ include_stack=[*include_stack, cycle_key],
222
+ ),
223
+ )
224
+
225
+ def _load_single(self, candidate: str) -> ResObj | None:
226
+ include_stack = self.opts.include_stack
227
+ if candidate in include_stack:
228
+ raise ResolveError(f"circular include: {candidate}", candidate, 0, 0)
229
+
230
+ try:
231
+ content = self.opts.read_file_sync(candidate)
232
+ except Exception as e:
233
+ if is_file_not_found_error(e):
234
+ return None
235
+ raise
236
+
237
+ if candidate.endswith(".properties"):
238
+ return hocon_value_to_res_obj(properties_to_hocon_value(content))
239
+
240
+ tokens = tokenize(content)
241
+ # Lightbend-compat carve-out (#105): empty/comment-only included file → {}.
242
+ if not has_content_tokens(tokens):
243
+ return make_res_obj()
244
+ ast = parse_tokens(tokens)
245
+ return self.on_build_res_obj(
246
+ ast,
247
+ dataclasses.replace(
248
+ self.opts,
249
+ base_dir=os.path.dirname(candidate),
250
+ include_stack=[*include_stack, candidate],
251
+ ),
252
+ )