devconfig-gen 1.0.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.
- devconfig_gen/__init__.py +90 -0
- devconfig_gen/cli.py +213 -0
- devconfig_gen/engine.py +217 -0
- devconfig_gen/formats.py +708 -0
- devconfig_gen/interactive.py +377 -0
- devconfig_gen/models.py +135 -0
- devconfig_gen/providers/__init__.py +7 -0
- devconfig_gen/providers/custom.py +92 -0
- devconfig_gen/providers/env_provider.py +184 -0
- devconfig_gen/providers/json_provider.py +84 -0
- devconfig_gen/py.typed +0 -0
- devconfig_gen/registry.py +38 -0
- devconfig_gen/validation.py +148 -0
- devconfig_gen/web_ui.py +2143 -0
- devconfig_gen-1.0.0.dist-info/METADATA +456 -0
- devconfig_gen-1.0.0.dist-info/RECORD +20 -0
- devconfig_gen-1.0.0.dist-info/WHEEL +5 -0
- devconfig_gen-1.0.0.dist-info/entry_points.txt +2 -0
- devconfig_gen-1.0.0.dist-info/licenses/LICENSE +201 -0
- devconfig_gen-1.0.0.dist-info/top_level.txt +1 -0
devconfig_gen/formats.py
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
"""Loading and serializing structured configuration data.
|
|
2
|
+
|
|
3
|
+
JSON is handled by the standard library. YAML is handled by PyYAML when it is
|
|
4
|
+
installed and otherwise by a bundled, dependency-free parser that covers the
|
|
5
|
+
subset of YAML used for configuration documents. See ``README.md`` for the
|
|
6
|
+
exact support boundary.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Mapping, Optional, Union
|
|
17
|
+
|
|
18
|
+
JSON = "json"
|
|
19
|
+
YAML = "yaml"
|
|
20
|
+
|
|
21
|
+
JSON_MEDIA_TYPE = "application/json"
|
|
22
|
+
YAML_MEDIA_TYPE = "application/yaml"
|
|
23
|
+
|
|
24
|
+
_MEDIA_TYPES = {
|
|
25
|
+
JSON: JSON_MEDIA_TYPE,
|
|
26
|
+
YAML: YAML_MEDIA_TYPE,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_FORMAT_ALIASES = {
|
|
30
|
+
"json": JSON,
|
|
31
|
+
"application/json": JSON,
|
|
32
|
+
"yaml": YAML,
|
|
33
|
+
"yml": YAML,
|
|
34
|
+
"application/yaml": YAML,
|
|
35
|
+
"application/x-yaml": YAML,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try: # pragma: no cover - exercised only when PyYAML is installed
|
|
39
|
+
import yaml as _pyyaml
|
|
40
|
+
except Exception: # pragma: no cover - the common case on a clean install
|
|
41
|
+
_pyyaml = None
|
|
42
|
+
|
|
43
|
+
HAS_PYYAML = _pyyaml is not None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FormatError(ValueError):
|
|
47
|
+
"""Raised when input cannot be parsed or output cannot be serialized."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class YamlError(FormatError):
|
|
51
|
+
"""Raised for errors raised by the bundled YAML subset parser."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def deep_merge(base: Any, overlay: Any) -> Any:
|
|
55
|
+
"""Recursively merge overlay into base."""
|
|
56
|
+
if isinstance(base, Mapping) and isinstance(overlay, Mapping):
|
|
57
|
+
merged = dict(base)
|
|
58
|
+
for key, value in overlay.items():
|
|
59
|
+
if key in merged and isinstance(merged[key], Mapping) and isinstance(value, Mapping):
|
|
60
|
+
merged[key] = deep_merge(merged[key], value)
|
|
61
|
+
else:
|
|
62
|
+
merged[key] = value
|
|
63
|
+
return merged
|
|
64
|
+
return overlay
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def normalize_format(value: Optional[str]) -> Optional[str]:
|
|
68
|
+
"""Return the canonical format name for ``value`` or ``None``."""
|
|
69
|
+
|
|
70
|
+
if value is None:
|
|
71
|
+
return None
|
|
72
|
+
key = str(value).strip().lower()
|
|
73
|
+
if not key:
|
|
74
|
+
return None
|
|
75
|
+
try:
|
|
76
|
+
return _FORMAT_ALIASES[key]
|
|
77
|
+
except KeyError as exc:
|
|
78
|
+
supported = ", ".join(sorted({JSON, YAML}))
|
|
79
|
+
raise FormatError(f"unsupported format {value!r}; expected one of: {supported}") from exc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def format_from_media_type(media_type: str) -> str:
|
|
83
|
+
key = str(media_type).strip().lower()
|
|
84
|
+
if key in (JSON_MEDIA_TYPE, JSON):
|
|
85
|
+
return JSON
|
|
86
|
+
if key in (YAML_MEDIA_TYPE, YAML):
|
|
87
|
+
return YAML
|
|
88
|
+
raise FormatError(f"unsupported media type {media_type!r}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def media_type_for(fmt: str) -> str:
|
|
92
|
+
canonical = normalize_format(fmt)
|
|
93
|
+
if canonical is None:
|
|
94
|
+
raise FormatError("format must not be empty")
|
|
95
|
+
return _MEDIA_TYPES[canonical]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def detect_format(*, path: Optional[Union[str, os.PathLike]] = None, text: Optional[str] = None) -> str:
|
|
99
|
+
"""Best-effort detection of a document format from a path and/or text."""
|
|
100
|
+
|
|
101
|
+
if path is not None:
|
|
102
|
+
suffix = Path(path).suffix.lower()
|
|
103
|
+
if suffix == ".json":
|
|
104
|
+
return JSON
|
|
105
|
+
if suffix in (".yaml", ".yml"):
|
|
106
|
+
return YAML
|
|
107
|
+
if text is not None:
|
|
108
|
+
stripped = text.lstrip("\ufeff \t\r\n")
|
|
109
|
+
if stripped.startswith("{") or stripped.startswith("["):
|
|
110
|
+
return JSON
|
|
111
|
+
return YAML
|
|
112
|
+
if path is not None:
|
|
113
|
+
return YAML
|
|
114
|
+
return JSON
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve_format(
|
|
118
|
+
explicit: Optional[str] = None,
|
|
119
|
+
*,
|
|
120
|
+
name: Optional[Union[str, os.PathLike]] = None,
|
|
121
|
+
path: Optional[Union[str, os.PathLike]] = None,
|
|
122
|
+
default: str = JSON,
|
|
123
|
+
) -> str:
|
|
124
|
+
"""Resolve the effective format from explicit input, a filename, or a path."""
|
|
125
|
+
|
|
126
|
+
canonical = normalize_format(explicit)
|
|
127
|
+
if canonical is not None:
|
|
128
|
+
return canonical
|
|
129
|
+
if name is not None:
|
|
130
|
+
suffix = Path(name).suffix.lower()
|
|
131
|
+
if suffix == ".json":
|
|
132
|
+
return JSON
|
|
133
|
+
if suffix in (".yaml", ".yml"):
|
|
134
|
+
return YAML
|
|
135
|
+
if path is not None:
|
|
136
|
+
return detect_format(path=path)
|
|
137
|
+
return normalize_format(default) or JSON
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def coerce_scalar(value: Any) -> Any:
|
|
141
|
+
"""Interpret a string as a JSON value when possible.
|
|
142
|
+
|
|
143
|
+
``"9090"`` becomes ``9090``, ``"true"`` becomes ``True``, ``"null"`` becomes
|
|
144
|
+
``None``. Non-JSON text such as ``"production"`` is returned unchanged, and
|
|
145
|
+
non-string inputs pass through untouched.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
if not isinstance(value, str):
|
|
149
|
+
return value
|
|
150
|
+
text = value.strip()
|
|
151
|
+
if text == "":
|
|
152
|
+
return value
|
|
153
|
+
try:
|
|
154
|
+
return json.loads(text)
|
|
155
|
+
except json.JSONDecodeError:
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def loads(text: str, fmt: Optional[str] = None) -> Any:
|
|
160
|
+
"""Parse ``text`` as JSON or YAML.
|
|
161
|
+
|
|
162
|
+
When ``fmt`` is omitted the format is detected: JSON is attempted first
|
|
163
|
+
because every JSON document is also a valid YAML document.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
if not isinstance(text, str):
|
|
167
|
+
raise FormatError(f"expected a string to parse, got {type(text).__name__}")
|
|
168
|
+
canonical = normalize_format(fmt)
|
|
169
|
+
if canonical is None:
|
|
170
|
+
canonical = detect_format(text=text)
|
|
171
|
+
if canonical == JSON:
|
|
172
|
+
try:
|
|
173
|
+
return json.loads(text)
|
|
174
|
+
except json.JSONDecodeError as exc:
|
|
175
|
+
raise FormatError(f"invalid JSON: {exc}") from exc
|
|
176
|
+
return _yaml_loads(text)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def load_file(path: Union[str, os.PathLike], fmt: Optional[str] = None) -> Any:
|
|
180
|
+
"""Read and parse a JSON or YAML file."""
|
|
181
|
+
|
|
182
|
+
file_path = Path(path)
|
|
183
|
+
try:
|
|
184
|
+
text = file_path.read_text(encoding="utf-8")
|
|
185
|
+
except OSError as exc:
|
|
186
|
+
raise FormatError(f"cannot read {file_path}: {exc}") from exc
|
|
187
|
+
canonical = normalize_format(fmt) or detect_format(path=file_path, text=text)
|
|
188
|
+
return loads(text, canonical)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def load_data(source: Union[str, os.PathLike], fmt: Optional[str] = None) -> Any:
|
|
192
|
+
"""Load structured data from a path or from raw document text.
|
|
193
|
+
|
|
194
|
+
``Path`` instances are always treated as files. Strings are treated as a
|
|
195
|
+
file path when they point at an existing file without newlines, and as
|
|
196
|
+
document text otherwise.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
if isinstance(source, os.PathLike):
|
|
200
|
+
return load_file(source, fmt)
|
|
201
|
+
if isinstance(source, str):
|
|
202
|
+
if "\n" not in source and "\r" not in source:
|
|
203
|
+
candidate = Path(source)
|
|
204
|
+
if candidate.exists() and candidate.is_file():
|
|
205
|
+
return load_file(candidate, fmt)
|
|
206
|
+
return loads(source, fmt)
|
|
207
|
+
raise FormatError(f"expected a path or document string, got {type(source).__name__}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def dumps(data: Any, fmt: str = JSON) -> str:
|
|
211
|
+
"""Serialize ``data`` to a JSON or YAML string."""
|
|
212
|
+
|
|
213
|
+
canonical = normalize_format(fmt)
|
|
214
|
+
if canonical is None:
|
|
215
|
+
raise FormatError("a format is required to serialize data")
|
|
216
|
+
if canonical == JSON:
|
|
217
|
+
return json.dumps(data, indent=2, sort_keys=False, ensure_ascii=False) + "\n"
|
|
218
|
+
return _yaml_dumps(data)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def dump_data(data: Any, fmt: str = JSON) -> str:
|
|
222
|
+
"""Alias of :func:`dumps` kept for a stable, descriptive public API."""
|
|
223
|
+
|
|
224
|
+
return dumps(data, fmt)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def dump_file(data: Any, path: Union[str, os.PathLike], fmt: Optional[str] = None) -> Path:
|
|
228
|
+
"""Serialize ``data`` to ``path``; the format follows the suffix if unset."""
|
|
229
|
+
|
|
230
|
+
file_path = Path(path)
|
|
231
|
+
canonical = resolve_format(fmt, name=file_path, default=JSON)
|
|
232
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
file_path.write_text(dumps(data, canonical), encoding="utf-8")
|
|
234
|
+
return file_path
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# YAML subset parser
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
_INT_RE = re.compile(r"^[+-]?\d+$")
|
|
242
|
+
_FLOAT_RE = re.compile(r"^[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?$")
|
|
243
|
+
_NULL_TOKENS = {"", "~", "null", "Null", "NULL"}
|
|
244
|
+
_TRUE_TOKENS = {"true", "True", "TRUE"}
|
|
245
|
+
_FALSE_TOKENS = {"false", "False", "FALSE"}
|
|
246
|
+
_BLOCK_INDICATORS = {"|", ">", "|-", ">-", "|+", ">+"}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class _Line:
|
|
250
|
+
__slots__ = ("indent", "content", "raw")
|
|
251
|
+
|
|
252
|
+
def __init__(self, indent, content, raw):
|
|
253
|
+
self.indent = indent
|
|
254
|
+
self.content = content
|
|
255
|
+
self.raw = raw
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _strip_comment(line: str) -> str:
|
|
259
|
+
in_single = False
|
|
260
|
+
in_double = False
|
|
261
|
+
out = []
|
|
262
|
+
for index, char in enumerate(line):
|
|
263
|
+
if char == "'" and not in_double:
|
|
264
|
+
in_single = not in_single
|
|
265
|
+
elif char == '"' and not in_single:
|
|
266
|
+
in_double = not in_double
|
|
267
|
+
elif char == "#" and not in_single and not in_double:
|
|
268
|
+
if index == 0 or line[index - 1] in " \t":
|
|
269
|
+
break
|
|
270
|
+
out.append(char)
|
|
271
|
+
return "".join(out)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _scan(text: str) -> list:
|
|
275
|
+
lines = []
|
|
276
|
+
for raw in text.splitlines():
|
|
277
|
+
stripped = raw.lstrip(" ")
|
|
278
|
+
indentation = raw[: len(raw) - len(stripped)]
|
|
279
|
+
if "\t" in indentation:
|
|
280
|
+
raise YamlError("tab characters are not allowed for indentation")
|
|
281
|
+
indent = len(indentation)
|
|
282
|
+
if stripped == "" or stripped.startswith("#"):
|
|
283
|
+
lines.append(_Line(None, "", raw))
|
|
284
|
+
continue
|
|
285
|
+
content = _strip_comment(stripped).rstrip()
|
|
286
|
+
if content == "":
|
|
287
|
+
lines.append(_Line(None, "", raw))
|
|
288
|
+
continue
|
|
289
|
+
lines.append(_Line(indent, content, raw))
|
|
290
|
+
return lines
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _split_key_value(content: str):
|
|
294
|
+
in_single = False
|
|
295
|
+
in_double = False
|
|
296
|
+
depth = 0
|
|
297
|
+
for index, char in enumerate(content):
|
|
298
|
+
if char == "'" and not in_double:
|
|
299
|
+
in_single = not in_single
|
|
300
|
+
elif char == '"' and not in_single:
|
|
301
|
+
in_double = not in_double
|
|
302
|
+
elif not in_single and not in_double:
|
|
303
|
+
if char in "[{":
|
|
304
|
+
depth += 1
|
|
305
|
+
elif char in "]}":
|
|
306
|
+
depth -= 1
|
|
307
|
+
elif char == ":" and depth == 0:
|
|
308
|
+
if index + 1 == len(content) or content[index + 1] in " \t":
|
|
309
|
+
key = content[:index].strip()
|
|
310
|
+
value = content[index + 1 :].strip()
|
|
311
|
+
return _parse_key(key), value
|
|
312
|
+
raise YamlError(f"invalid mapping entry: {content!r}")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _parse_key(raw: str) -> str:
|
|
316
|
+
raw = raw.strip()
|
|
317
|
+
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'":
|
|
318
|
+
return str(_parse_quoted(raw))
|
|
319
|
+
return raw
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _parse_quoted(raw: str):
|
|
323
|
+
if raw.startswith('"'):
|
|
324
|
+
try:
|
|
325
|
+
return json.loads(raw)
|
|
326
|
+
except json.JSONDecodeError as exc:
|
|
327
|
+
raise YamlError(f"invalid double-quoted string {raw!r}: {exc}") from exc
|
|
328
|
+
inner = raw[1:-1].replace("''", "'")
|
|
329
|
+
return inner
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _parse_scalar(raw: str):
|
|
333
|
+
text = raw.strip()
|
|
334
|
+
if text == "" or text in _NULL_TOKENS:
|
|
335
|
+
return None
|
|
336
|
+
if text.startswith("[") or text.startswith("{"):
|
|
337
|
+
return _parse_flow(text)
|
|
338
|
+
if text.startswith('"') or text.startswith("'"):
|
|
339
|
+
if len(text) < 2 or text[-1] != text[0]:
|
|
340
|
+
raise YamlError(f"unterminated quoted string: {raw!r}")
|
|
341
|
+
return _parse_quoted(text)
|
|
342
|
+
if text in _TRUE_TOKENS:
|
|
343
|
+
return True
|
|
344
|
+
if text in _FALSE_TOKENS:
|
|
345
|
+
return False
|
|
346
|
+
if _INT_RE.match(text):
|
|
347
|
+
return int(text)
|
|
348
|
+
if _FLOAT_RE.match(text):
|
|
349
|
+
return float(text)
|
|
350
|
+
if text in (".inf", ".Inf", ".INF", "+.inf"):
|
|
351
|
+
return math.inf
|
|
352
|
+
if text in ("-.inf", "-.Inf", "-.INF"):
|
|
353
|
+
return -math.inf
|
|
354
|
+
if text in (".nan", ".NaN", ".NAN"):
|
|
355
|
+
return math.nan
|
|
356
|
+
return text
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _split_flow(inner: str) -> list:
|
|
360
|
+
parts = []
|
|
361
|
+
depth = 0
|
|
362
|
+
in_single = False
|
|
363
|
+
in_double = False
|
|
364
|
+
current = []
|
|
365
|
+
for char in inner:
|
|
366
|
+
if char == "'" and not in_double:
|
|
367
|
+
in_single = not in_single
|
|
368
|
+
elif char == '"' and not in_single:
|
|
369
|
+
in_double = not in_double
|
|
370
|
+
elif not in_single and not in_double:
|
|
371
|
+
if char in "[{":
|
|
372
|
+
depth += 1
|
|
373
|
+
elif char in "]}":
|
|
374
|
+
depth -= 1
|
|
375
|
+
elif char == "," and depth == 0:
|
|
376
|
+
parts.append("".join(current))
|
|
377
|
+
current = []
|
|
378
|
+
continue
|
|
379
|
+
current.append(char)
|
|
380
|
+
parts.append("".join(current))
|
|
381
|
+
return [part for part in parts if part.strip() != ""]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _parse_flow(text: str):
|
|
385
|
+
if text.startswith("["):
|
|
386
|
+
if not text.endswith("]"):
|
|
387
|
+
raise YamlError(f"unterminated flow sequence: {text!r}")
|
|
388
|
+
inner = text[1:-1].strip()
|
|
389
|
+
if inner == "":
|
|
390
|
+
return []
|
|
391
|
+
return [_parse_scalar(part) for part in _split_flow(inner)]
|
|
392
|
+
if text.startswith("{"):
|
|
393
|
+
if not text.endswith("}"):
|
|
394
|
+
raise YamlError(f"unterminated flow mapping: {text!r}")
|
|
395
|
+
inner = text[1:-1].strip()
|
|
396
|
+
if inner == "":
|
|
397
|
+
return {}
|
|
398
|
+
result = {}
|
|
399
|
+
for part in _split_flow(inner):
|
|
400
|
+
key, value = _split_key_value(part.strip())
|
|
401
|
+
result[key] = _parse_scalar(value) if value.strip() else None
|
|
402
|
+
return result
|
|
403
|
+
raise YamlError(f"invalid flow collection: {text!r}")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class _Parser:
|
|
407
|
+
def __init__(self, lines):
|
|
408
|
+
self.lines = lines
|
|
409
|
+
self.pos = 0
|
|
410
|
+
|
|
411
|
+
def _skip_blanks(self):
|
|
412
|
+
while self.pos < len(self.lines) and self.lines[self.pos].indent is None:
|
|
413
|
+
self.pos += 1
|
|
414
|
+
|
|
415
|
+
def parse(self):
|
|
416
|
+
self._skip_blanks()
|
|
417
|
+
if self.pos >= len(self.lines):
|
|
418
|
+
return None
|
|
419
|
+
if self.lines[self.pos].content in ("---", "..."):
|
|
420
|
+
self.pos += 1
|
|
421
|
+
self._skip_blanks()
|
|
422
|
+
if self.pos >= len(self.lines):
|
|
423
|
+
return None
|
|
424
|
+
value = self._parse_node(self.lines[self.pos].indent)
|
|
425
|
+
self._skip_blanks()
|
|
426
|
+
if self.pos < len(self.lines):
|
|
427
|
+
line = self.lines[self.pos]
|
|
428
|
+
raise YamlError(f"unexpected content at line {self.pos + 1}: {line.content!r}")
|
|
429
|
+
return value
|
|
430
|
+
|
|
431
|
+
def _parse_node(self, indent):
|
|
432
|
+
line = self.lines[self.pos]
|
|
433
|
+
if line.content == "-" or line.content.startswith("- "):
|
|
434
|
+
return self._parse_sequence(indent)
|
|
435
|
+
return self._parse_mapping(indent)
|
|
436
|
+
|
|
437
|
+
def _parse_sequence(self, indent):
|
|
438
|
+
items = []
|
|
439
|
+
while True:
|
|
440
|
+
self._skip_blanks()
|
|
441
|
+
if self.pos >= len(self.lines):
|
|
442
|
+
break
|
|
443
|
+
line = self.lines[self.pos]
|
|
444
|
+
if line.indent is None:
|
|
445
|
+
continue
|
|
446
|
+
if line.indent < indent:
|
|
447
|
+
break
|
|
448
|
+
if line.indent > indent:
|
|
449
|
+
raise YamlError(f"unexpected indentation at line {self.pos + 1}: {line.content!r}")
|
|
450
|
+
content = line.content
|
|
451
|
+
if not (content == "-" or content.startswith("- ")):
|
|
452
|
+
break
|
|
453
|
+
rest = content[2:].strip() if content.startswith("- ") else ""
|
|
454
|
+
if rest == "":
|
|
455
|
+
self.pos += 1
|
|
456
|
+
self._skip_blanks()
|
|
457
|
+
if (
|
|
458
|
+
self.pos < len(self.lines)
|
|
459
|
+
and self.lines[self.pos].indent is not None
|
|
460
|
+
and self.lines[self.pos].indent > indent
|
|
461
|
+
):
|
|
462
|
+
items.append(self._parse_node(self.lines[self.pos].indent))
|
|
463
|
+
else:
|
|
464
|
+
items.append(None)
|
|
465
|
+
elif _looks_like_mapping_entry(rest):
|
|
466
|
+
self.lines[self.pos] = _Line(indent + 2, rest, self.lines[self.pos].raw)
|
|
467
|
+
items.append(self._parse_mapping(indent + 2))
|
|
468
|
+
else:
|
|
469
|
+
items.append(_parse_scalar(rest))
|
|
470
|
+
self.pos += 1
|
|
471
|
+
return items
|
|
472
|
+
|
|
473
|
+
def _parse_mapping(self, indent):
|
|
474
|
+
result = {}
|
|
475
|
+
while True:
|
|
476
|
+
self._skip_blanks()
|
|
477
|
+
if self.pos >= len(self.lines):
|
|
478
|
+
break
|
|
479
|
+
line = self.lines[self.pos]
|
|
480
|
+
if line.indent is None:
|
|
481
|
+
continue
|
|
482
|
+
if line.indent < indent:
|
|
483
|
+
break
|
|
484
|
+
if line.indent > indent:
|
|
485
|
+
raise YamlError(f"unexpected indentation at line {self.pos + 1}: {line.content!r}")
|
|
486
|
+
key, value = _split_key_value(line.content)
|
|
487
|
+
self.pos += 1
|
|
488
|
+
if value == "":
|
|
489
|
+
saved = self.pos
|
|
490
|
+
self._skip_blanks()
|
|
491
|
+
if (
|
|
492
|
+
self.pos < len(self.lines)
|
|
493
|
+
and self.lines[self.pos].indent is not None
|
|
494
|
+
and self.lines[self.pos].indent > indent
|
|
495
|
+
):
|
|
496
|
+
result[key] = self._parse_node(self.lines[self.pos].indent)
|
|
497
|
+
else:
|
|
498
|
+
self.pos = saved
|
|
499
|
+
result[key] = None
|
|
500
|
+
elif value in _BLOCK_INDICATORS:
|
|
501
|
+
result[key] = self._parse_block_scalar(indent, value)
|
|
502
|
+
else:
|
|
503
|
+
result[key] = _parse_scalar(value)
|
|
504
|
+
return result
|
|
505
|
+
|
|
506
|
+
def _parse_block_scalar(self, parent_indent, indicator):
|
|
507
|
+
collected = []
|
|
508
|
+
block_indent = None
|
|
509
|
+
while self.pos < len(self.lines):
|
|
510
|
+
line = self.lines[self.pos]
|
|
511
|
+
if line.indent is None:
|
|
512
|
+
collected.append(None)
|
|
513
|
+
self.pos += 1
|
|
514
|
+
continue
|
|
515
|
+
if line.indent <= parent_indent:
|
|
516
|
+
break
|
|
517
|
+
if block_indent is None:
|
|
518
|
+
block_indent = line.indent
|
|
519
|
+
collected.append(line.raw[block_indent:] if len(line.raw) >= block_indent else line.content)
|
|
520
|
+
self.pos += 1
|
|
521
|
+
|
|
522
|
+
keep = indicator.endswith("+")
|
|
523
|
+
strip = indicator.endswith("-")
|
|
524
|
+
|
|
525
|
+
trailing_blanks = 0
|
|
526
|
+
while collected and collected[-1] is None:
|
|
527
|
+
collected.pop()
|
|
528
|
+
trailing_blanks += 1
|
|
529
|
+
|
|
530
|
+
raw_lines = ["" if item is None else item for item in collected]
|
|
531
|
+
if indicator[0] == ">":
|
|
532
|
+
parts = []
|
|
533
|
+
pending_breaks = 0
|
|
534
|
+
first = True
|
|
535
|
+
for item in raw_lines:
|
|
536
|
+
if item == "":
|
|
537
|
+
pending_breaks += 1
|
|
538
|
+
continue
|
|
539
|
+
if first:
|
|
540
|
+
parts.append(item)
|
|
541
|
+
first = False
|
|
542
|
+
elif pending_breaks:
|
|
543
|
+
parts.append("\n" * pending_breaks + item)
|
|
544
|
+
pending_breaks = 0
|
|
545
|
+
else:
|
|
546
|
+
parts.append(" " + item)
|
|
547
|
+
core = "".join(parts)
|
|
548
|
+
else:
|
|
549
|
+
core = "\n".join(raw_lines)
|
|
550
|
+
|
|
551
|
+
if strip:
|
|
552
|
+
return core.rstrip("\n")
|
|
553
|
+
if keep:
|
|
554
|
+
if not core:
|
|
555
|
+
return "\n" * trailing_blanks
|
|
556
|
+
return core.rstrip("\n") + "\n" * (1 + trailing_blanks)
|
|
557
|
+
if not core:
|
|
558
|
+
return ""
|
|
559
|
+
return core.rstrip("\n") + "\n"
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _looks_like_mapping_entry(text: str) -> bool:
|
|
563
|
+
in_single = False
|
|
564
|
+
in_double = False
|
|
565
|
+
depth = 0
|
|
566
|
+
for index, char in enumerate(text):
|
|
567
|
+
if char == "'" and not in_double:
|
|
568
|
+
in_single = not in_single
|
|
569
|
+
elif char == '"' and not in_single:
|
|
570
|
+
in_double = not in_double
|
|
571
|
+
elif not in_single and not in_double:
|
|
572
|
+
if char in "[{":
|
|
573
|
+
depth += 1
|
|
574
|
+
elif char in "]}":
|
|
575
|
+
depth -= 1
|
|
576
|
+
elif char == ":" and depth == 0:
|
|
577
|
+
if index + 1 == len(text) or text[index + 1] in " \t":
|
|
578
|
+
return True
|
|
579
|
+
return False
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _yaml_loads(text: str):
|
|
583
|
+
if HAS_PYYAML:
|
|
584
|
+
try:
|
|
585
|
+
return _pyyaml.safe_load(text)
|
|
586
|
+
except _pyyaml.YAMLError as exc: # pragma: no cover - depends on PyYAML
|
|
587
|
+
raise YamlError(f"invalid YAML: {exc}") from exc
|
|
588
|
+
if not isinstance(text, str):
|
|
589
|
+
raise YamlError("expected YAML text")
|
|
590
|
+
return _Parser(_scan(text)).parse()
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
# ---------------------------------------------------------------------------
|
|
594
|
+
# YAML subset serializer
|
|
595
|
+
# ---------------------------------------------------------------------------
|
|
596
|
+
|
|
597
|
+
_PLAIN_SAFE_RE = re.compile(r"^[A-Za-z0-9_/][A-Za-z0-9_\-./ ]*$")
|
|
598
|
+
_PLAIN_RESERVED = {
|
|
599
|
+
"null",
|
|
600
|
+
"true",
|
|
601
|
+
"false",
|
|
602
|
+
"yes",
|
|
603
|
+
"no",
|
|
604
|
+
"on",
|
|
605
|
+
"off",
|
|
606
|
+
"~",
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _is_number_like(text: str) -> bool:
|
|
611
|
+
return bool(_INT_RE.match(text) or _FLOAT_RE.match(text))
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _yaml_string(value: str) -> str:
|
|
615
|
+
if value == "":
|
|
616
|
+
return '""'
|
|
617
|
+
needs_quotes = (
|
|
618
|
+
value != value.strip()
|
|
619
|
+
or "\n" in value
|
|
620
|
+
or "\r" in value
|
|
621
|
+
or "\t" in value
|
|
622
|
+
or any(ord(char) < 0x20 for char in value)
|
|
623
|
+
or value.lower() in _PLAIN_RESERVED
|
|
624
|
+
or _is_number_like(value)
|
|
625
|
+
or not _PLAIN_SAFE_RE.match(value)
|
|
626
|
+
)
|
|
627
|
+
if needs_quotes:
|
|
628
|
+
return json.dumps(value, ensure_ascii=False)
|
|
629
|
+
return value
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _yaml_scalar(value) -> str:
|
|
633
|
+
if value is None:
|
|
634
|
+
return "null"
|
|
635
|
+
if value is True:
|
|
636
|
+
return "true"
|
|
637
|
+
if value is False:
|
|
638
|
+
return "false"
|
|
639
|
+
if isinstance(value, bool):
|
|
640
|
+
return "true" if value else "false"
|
|
641
|
+
if isinstance(value, int):
|
|
642
|
+
return str(value)
|
|
643
|
+
if isinstance(value, float):
|
|
644
|
+
if math.isnan(value):
|
|
645
|
+
return ".nan"
|
|
646
|
+
if math.isinf(value):
|
|
647
|
+
return ".inf" if value > 0 else "-.inf"
|
|
648
|
+
return repr(value)
|
|
649
|
+
if isinstance(value, str):
|
|
650
|
+
return _yaml_string(value)
|
|
651
|
+
raise FormatError(f"cannot serialize value of type {type(value).__name__} to YAML")
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _yaml_block(data, indent: int) -> list:
|
|
655
|
+
prefix = " " * indent
|
|
656
|
+
lines = []
|
|
657
|
+
if isinstance(data, Mapping):
|
|
658
|
+
if not data:
|
|
659
|
+
lines.append(prefix + "{}")
|
|
660
|
+
return lines
|
|
661
|
+
for key, value in data.items():
|
|
662
|
+
key_text = _yaml_string(str(key))
|
|
663
|
+
if isinstance(value, Mapping) and value:
|
|
664
|
+
lines.append(f"{prefix}{key_text}:")
|
|
665
|
+
lines.extend(_yaml_block(value, indent + 2))
|
|
666
|
+
elif isinstance(value, (list, tuple)) and value:
|
|
667
|
+
lines.append(f"{prefix}{key_text}:")
|
|
668
|
+
lines.extend(_yaml_block(value, indent + 2))
|
|
669
|
+
elif isinstance(value, Mapping):
|
|
670
|
+
lines.append(f"{prefix}{key_text}: {{}}")
|
|
671
|
+
elif isinstance(value, (list, tuple)):
|
|
672
|
+
lines.append(f"{prefix}{key_text}: []")
|
|
673
|
+
else:
|
|
674
|
+
lines.append(f"{prefix}{key_text}: {_yaml_scalar(value)}")
|
|
675
|
+
return lines
|
|
676
|
+
if isinstance(data, (list, tuple)):
|
|
677
|
+
if not data:
|
|
678
|
+
lines.append(prefix + "[]")
|
|
679
|
+
return lines
|
|
680
|
+
for item in data:
|
|
681
|
+
if isinstance(item, Mapping) and item:
|
|
682
|
+
sub = _yaml_block(item, indent + 2)
|
|
683
|
+
first = sub[0]
|
|
684
|
+
lines.append(f"{prefix}- {first[indent + 2:]}")
|
|
685
|
+
lines.extend(sub[1:])
|
|
686
|
+
elif isinstance(item, (list, tuple)) and item:
|
|
687
|
+
lines.append(prefix + "-")
|
|
688
|
+
lines.extend(_yaml_block(item, indent + 2))
|
|
689
|
+
elif isinstance(item, Mapping):
|
|
690
|
+
lines.append(prefix + "- {}")
|
|
691
|
+
elif isinstance(item, (list, tuple)):
|
|
692
|
+
lines.append(prefix + "- []")
|
|
693
|
+
else:
|
|
694
|
+
lines.append(f"{prefix}- {_yaml_scalar(item)}")
|
|
695
|
+
return lines
|
|
696
|
+
lines.append(f"{prefix}{_yaml_scalar(data)}")
|
|
697
|
+
return lines
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _yaml_dumps(data) -> str:
|
|
701
|
+
if HAS_PYYAML: # pragma: no cover - depends on PyYAML
|
|
702
|
+
return _pyyaml.safe_dump(
|
|
703
|
+
data,
|
|
704
|
+
default_flow_style=False,
|
|
705
|
+
sort_keys=False,
|
|
706
|
+
allow_unicode=True,
|
|
707
|
+
)
|
|
708
|
+
return "\n".join(_yaml_block(data, 0)) + "\n"
|