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 +68 -0
- hocon/_internal/__init__.py +4 -0
- hocon/_internal/lexer/__init__.py +1 -0
- hocon/_internal/lexer/lexer.py +500 -0
- hocon/_internal/lexer/token.py +65 -0
- hocon/_internal/parser/__init__.py +1 -0
- hocon/_internal/parser/ast.py +120 -0
- hocon/_internal/parser/empty_check.py +29 -0
- hocon/_internal/parser/parser.py +533 -0
- hocon/_internal/properties/__init__.py +4 -0
- hocon/_internal/properties/properties.py +84 -0
- hocon/_internal/resolver/__init__.py +1 -0
- hocon/_internal/resolver/fold_self_ref.py +282 -0
- hocon/_internal/resolver/include_loader.py +252 -0
- hocon/_internal/resolver/resolver.py +95 -0
- hocon/_internal/resolver/structure_builder.py +184 -0
- hocon/_internal/resolver/substitution_resolver.py +407 -0
- hocon/_internal/resolver/types.py +162 -0
- hocon/_internal/resolver/utils.py +144 -0
- hocon/coerce.py +257 -0
- hocon/config.py +524 -0
- hocon/errors.py +71 -0
- hocon/numeric_array.py +51 -0
- hocon/parse.py +122 -0
- hocon/py.typed +0 -0
- hocon/value.py +111 -0
- hocon/value_factory.py +71 -0
- hocon_parser-1.8.0.dist-info/METADATA +493 -0
- hocon_parser-1.8.0.dist-info/RECORD +31 -0
- hocon_parser-1.8.0.dist-info/WHEEL +4 -0
- hocon_parser-1.8.0.dist-info/licenses/LICENSE +202 -0
|
@@ -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
|
+
)
|