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.
@@ -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"