dirigent-blocks 0.9.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,36 @@
1
+ """The generic HTTP connection kind, and the client one is turned into.
2
+
3
+ The configuration it registers is :class:`dirigent_common.HttpConnectionConfig`, so an adapter
4
+ pack presents the same HTTP connection as this one rather than redefining base URL, auth, TLS
5
+ and timeouts into a fourth slightly different form.
6
+ """
7
+
8
+ from typing import ClassVar
9
+
10
+ import httpx2
11
+ from pydantic import BaseModel
12
+
13
+ from dirigent_common import HealthReport, HttpConnectionConfig, build_client
14
+ from dirigent_plugin import ConnectionKind
15
+
16
+
17
+ class HttpConnectionKind(ConnectionKind):
18
+ """The connection kind every generic HTTP block resolves its credentials through."""
19
+
20
+ id: ClassVar[str] = "http"
21
+ config_model: ClassVar[type[BaseModel]] = HttpConnectionConfig
22
+
23
+ async def check(self, config: BaseModel) -> HealthReport:
24
+ """Request the health path and report whether the service answered."""
25
+ settings = HttpConnectionConfig.model_validate(config.model_dump())
26
+ try:
27
+ async with build_client(settings) as client:
28
+ response = await client.get(settings.health_path)
29
+ except httpx2.HTTPError as error:
30
+ return HealthReport(healthy=False, detail=f"{type(error).__name__}: {error}")
31
+ healthy = response.status_code < 500
32
+ return HealthReport(
33
+ healthy=healthy,
34
+ detail=f"HTTP {response.status_code}",
35
+ version=response.headers.get("server"),
36
+ )
@@ -0,0 +1,554 @@
1
+ """The ``std`` engine for the convert verb: json, ndjson, csv, yaml and xml, on the standard library."""
2
+
3
+ import csv
4
+ import datetime
5
+ import io
6
+ import json
7
+ import math
8
+ import re
9
+ import xml.etree.ElementTree as ET
10
+ from collections.abc import Iterator, Mapping, Sequence
11
+ from typing import Any, Final, cast
12
+
13
+ import yaml
14
+ from pydantic import JsonValue
15
+
16
+ from dirigent_plugin import Converter, TransformError
17
+
18
+ #: The three spellings of a sequence of records, which convert between each other freely.
19
+ SEQUENCE_FORMATS: Final = ("json", "ndjson", "csv")
20
+
21
+ #: Every pair this engine re-encodes, source first. The frame refuses everything else.
22
+ PAIRS: Final = frozenset(
23
+ {(source, target) for source in SEQUENCE_FORMATS for target in SEQUENCE_FORMATS if source != target}
24
+ | {("json", "yaml"), ("yaml", "json"), ("ndjson", "yaml"), ("yaml", "ndjson")}
25
+ | {("json", "xml"), ("xml", "json"), ("xml", "ndjson")}
26
+ )
27
+
28
+ #: What a target format calls one element of the sequence it writes.
29
+ UNIT: Final = {"json": "element", "ndjson": "line", "csv": "row", "yaml": "document"}
30
+
31
+ #: The key an element's attribute takes, prefixed so it can never collide with a child's tag.
32
+ ATTRIBUTE_PREFIX: Final = "@"
33
+
34
+ #: The key an element's text takes where the element also has attributes.
35
+ TEXT_KEY: Final = "#text"
36
+
37
+ #: An XML name, in ElementTree's spelling: a local name, optionally behind a ``{uri}``.
38
+ XML_NAME: Final = re.compile(r"(\{[^{}]+\})?[A-Za-z_][\w.\-]*\Z")
39
+
40
+
41
+ def _named(value: JsonValue) -> str:
42
+ """Say what a JSON value is, so a refusal names what arrived and not only what was wanted."""
43
+ if isinstance(value, dict):
44
+ return "an object"
45
+ if isinstance(value, list):
46
+ return "an array"
47
+ if isinstance(value, str):
48
+ return "a string"
49
+ # Before the number check, because a bool is an int in Python and is not one in JSON.
50
+ if isinstance(value, bool):
51
+ return "a boolean"
52
+ if value is None:
53
+ return "null"
54
+ return "a number"
55
+
56
+
57
+ class StdConverter(Converter):
58
+ """Re-encodes records and values between json, ndjson, csv, yaml and xml.
59
+
60
+ json, ndjson and csv say the same thing: a sequence of records. json says it as one
61
+ array, ndjson as one JSON value per line, and csv as a header and its rows. So a
62
+ conversion between those three is reading the sequence out of one spelling and writing
63
+ it in another, and everything the engine refuses there is a source that is not that
64
+ sequence.
65
+
66
+ csv is the format that carries less than the other two. A cell is text, so everything
67
+ read out of a csv is a string, and a record with a nested value has no csv spelling at
68
+ all. Both are refusals rather than conversions, because a codec that guesses a number
69
+ out of text, or flattens an object into a cell, has not preserved the content.
70
+
71
+ yaml is read and written two ways, because YAML spells two things. A YAML document is
72
+ one value, so yaml and json trade one whole value either way and a config file becomes
73
+ the object it describes. A YAML stream is many documents, so yaml and ndjson trade one
74
+ document per line. A YAML value JSON has no word for -- a date, a set, binary, a tagged
75
+ node, a mapping key that is not a string, a number that is nan or infinite -- is refused
76
+ naming its path, because writing it as text would be a guess about what it meant.
77
+
78
+ xml carries one root element, and the mapping is the one that round-trips: an element is
79
+ an object, an attribute is a key prefixed with ``@``, the text is ``#text`` where the
80
+ element has attributes and the bare string where it has none, a child element is a key of
81
+ its tag, and a tag that repeats is a list in document order. The prefix is what makes it
82
+ reversible: ``@`` cannot start an XML name, so an attribute and a child of the same name
83
+ never collide and every key says which it came from. An empty element is null, everything
84
+ read out of an element is text the way everything read out of a csv is, and mixed content
85
+ -- text beside child elements -- is refused naming the element, because the mapping has
86
+ nowhere to put it. json to xml therefore wants an object of exactly one key, which names
87
+ the root element.
88
+
89
+ xml to ndjson is the streaming direction: the root's children are the records, one line
90
+ each, read with ``iterparse`` and dropped as they are written, so a document far larger
91
+ than the records it holds converts. It has no reverse, because a line carries no root
92
+ element name to write one under; convert to json and name the root there.
93
+
94
+ No document type declaration is read. A DTD is where entities and external files enter an
95
+ XML document, so one is refused rather than parsed.
96
+ """
97
+
98
+ kind = "std"
99
+ summary = "Convert between json, ndjson, csv, yaml and xml."
100
+ pairs = PAIRS
101
+
102
+ def convert(self, source: bytes, *, source_format: str, target_format: str) -> bytes:
103
+ """Re-encode the payload, by whole value where yaml and json meet and by sequence otherwise."""
104
+ text = _decode(source)
105
+ if (source_format, target_format) == ("yaml", "json"):
106
+ return _dump_json(_read_yaml_document(text))
107
+ if (source_format, target_format) == ("json", "yaml"):
108
+ return _dump_yaml(_read_json_value(text))
109
+ if source_format == "xml":
110
+ return _read_xml(text, target_format)
111
+ if target_format == "xml":
112
+ return _write_xml(_read_json_value(text))
113
+ records = _read(text, source_format, target_format)
114
+ return _write(records, target_format).encode()
115
+
116
+
117
+ def _decode(source: bytes) -> str:
118
+ """Read the payload as UTF-8, refusing bytes that are not, at the byte that is not."""
119
+ try:
120
+ return source.decode("utf-8")
121
+ except UnicodeDecodeError as error:
122
+ raise TransformError(
123
+ f"the input is not UTF-8 text: {error.reason} at byte {error.start}; convert.std reads and writes UTF-8"
124
+ ) from error
125
+
126
+
127
+ def _dump_json(value: JsonValue) -> bytes:
128
+ """Write one JSON value, compactly, as the bytes the frame hands on."""
129
+ return json.dumps(value, separators=(",", ":")).encode()
130
+
131
+
132
+ def _read(text: str, source_format: str, target_format: str) -> list[JsonValue]:
133
+ """Read the sequence of records the source format spells out."""
134
+ if source_format == "json":
135
+ return _read_json(text, target_format)
136
+ if source_format == "ndjson":
137
+ return _read_ndjson(text)
138
+ if source_format == "yaml":
139
+ return _read_yaml_stream(text)
140
+ return _read_csv(text)
141
+
142
+
143
+ def _read_json_value(text: str) -> JsonValue:
144
+ """Read one JSON value of any shape, for the targets that carry a value rather than a sequence."""
145
+ try:
146
+ parsed: JsonValue = json.loads(text)
147
+ except ValueError as error:
148
+ raise TransformError(f"the input is not JSON: {error}") from error
149
+ return parsed
150
+
151
+
152
+ def _read_json(text: str, target_format: str) -> list[JsonValue]:
153
+ """Read one JSON array, refusing any other JSON value: a sequence is what is being converted."""
154
+ parsed = _read_json_value(text)
155
+ if not isinstance(parsed, list):
156
+ raise TransformError(
157
+ f"json to {target_format} writes one {UNIT[target_format]} per element, so the input has to be "
158
+ f"a JSON array, and this one is {_named(parsed)}"
159
+ )
160
+ return parsed
161
+
162
+
163
+ def _read_ndjson(text: str) -> list[JsonValue]:
164
+ """Read one JSON value per line, skipping blank lines and naming the line that is not JSON."""
165
+ records: list[JsonValue] = []
166
+ for number, line in enumerate(text.splitlines(), start=1):
167
+ if not line.strip():
168
+ continue
169
+ try:
170
+ records.append(json.loads(line))
171
+ except ValueError as error:
172
+ raise TransformError(f"line {number} of the input is not JSON: {error}") from error
173
+ return records
174
+
175
+
176
+ def _read_csv(text: str) -> list[JsonValue]:
177
+ """Read the header row and its rows, as objects whose every value is a string."""
178
+ rows = csv.reader(io.StringIO(text, newline=""))
179
+ header = next(rows, None)
180
+ if header is None:
181
+ return []
182
+ _check_header(header)
183
+ records: list[JsonValue] = []
184
+ for number, row in enumerate(rows, start=1):
185
+ if not row:
186
+ continue
187
+ if len(row) > len(header):
188
+ raise TransformError(
189
+ f"row {number} of the csv has more cells than the header names columns, "
190
+ f"and a cell no column names has nowhere to go"
191
+ )
192
+ records.append({name: row[index] if index < len(row) else "" for index, name in enumerate(header)})
193
+ return records
194
+
195
+
196
+ def _columns_phrase(positions: list[int]) -> str:
197
+ """Word one or more column positions, counting from one."""
198
+ listed = ", ".join(str(position) for position in positions)
199
+ return f"column {listed}" if len(positions) == 1 else f"columns {listed}"
200
+
201
+
202
+ def _check_header(header: list[str]) -> None:
203
+ """Refuse a header that leaves a name empty or repeats one.
204
+
205
+ A record key names its column, so neither has a record spelling: keeping one of two
206
+ columns that share a name, or keying a column by the empty string, drops content while
207
+ reporting success.
208
+ """
209
+ positions: dict[str, list[int]] = {}
210
+ for index, name in enumerate(header, start=1):
211
+ positions.setdefault(name, []).append(index)
212
+ unnamed = positions.pop("", None)
213
+ if unnamed is not None:
214
+ raise TransformError(
215
+ f"the csv header has no name at {_columns_phrase(unnamed)}, and a record key names "
216
+ f"its column; name it before converting"
217
+ )
218
+ repeated = [(name, where) for name, where in positions.items() if len(where) > 1]
219
+ if repeated:
220
+ listed = "; ".join(f"{name!r} at {_columns_phrase(where)}" for name, where in repeated)
221
+ raise TransformError(
222
+ f"the csv header repeats a column name: {listed}; a record key names one column, "
223
+ f"so rename them before converting"
224
+ )
225
+
226
+
227
+ def _write(records: list[JsonValue], target_format: str) -> str:
228
+ """Write the sequence of records in the target format."""
229
+ if target_format == "json":
230
+ return json.dumps(records, separators=(",", ":"))
231
+ if target_format == "ndjson":
232
+ return "".join(f"{json.dumps(record, separators=(',', ':'))}\n" for record in records)
233
+ if target_format == "yaml":
234
+ return _dump_yaml_stream(records)
235
+ return _write_csv(records)
236
+
237
+
238
+ def _write_csv(records: list[JsonValue]) -> str:
239
+ """Write a header of every key any row has, in first-seen order, and one row per record."""
240
+ rows: list[dict[str, JsonValue]] = []
241
+ header: list[str] = []
242
+ for number, record in enumerate(records, start=1):
243
+ if not isinstance(record, dict):
244
+ raise TransformError(f"row {number} of the input is {_named(record)}, and a csv row is a flat object")
245
+ rows.append(record)
246
+ header.extend(key for key in record if key not in header)
247
+ out = io.StringIO(newline="")
248
+ writer = csv.writer(out, lineterminator="\n")
249
+ writer.writerow(header)
250
+ for number, row in enumerate(rows, start=1):
251
+ writer.writerow([_cell(row.get(key), number, key) for key in header])
252
+ return out.getvalue()
253
+
254
+
255
+ def _cell(value: JsonValue, number: int, key: str) -> str:
256
+ """Render one value as csv text, refusing the nested ones csv has no spelling for."""
257
+ if isinstance(value, dict | list):
258
+ raise TransformError(
259
+ f"row {number} has a nested value at {key!r}, and a csv cell holds one value; "
260
+ f"flatten it before converting, because writing it as text would not be the same content"
261
+ )
262
+ if value is None:
263
+ return ""
264
+ if isinstance(value, str):
265
+ return value
266
+ return json.dumps(value)
267
+
268
+
269
+ class _Tagged:
270
+ """A node carrying a YAML tag the safe loader has no type for, kept so a refusal can name its path."""
271
+
272
+ def __init__(self, tag: str) -> None:
273
+ self.tag = tag
274
+
275
+
276
+ class _JsonSafeLoader(yaml.SafeLoader):
277
+ """The safe loader, with an unknown tag built into a marker rather than refused without a path."""
278
+
279
+
280
+ def _construct_tagged(loader: yaml.SafeLoader, suffix: str, node: yaml.Node) -> _Tagged:
281
+ """Build the marker for any tag the safe loader has no constructor of its own for."""
282
+ return _Tagged(node.tag)
283
+
284
+
285
+ yaml.add_multi_constructor("", _construct_tagged, Loader=_JsonSafeLoader)
286
+
287
+
288
+ def _load_yaml(text: str) -> list[Any]:
289
+ """Read every document of a YAML stream with the safe loader, naming where it stops."""
290
+ try:
291
+ return list(yaml.load_all(text, Loader=_JsonSafeLoader))
292
+ except yaml.YAMLError as error:
293
+ raise TransformError(f"the input is not YAML: {' '.join(str(error).split())}") from error
294
+
295
+
296
+ def _read_yaml_document(text: str) -> JsonValue:
297
+ """Read one YAML document as one value, refusing a stream that holds more than one."""
298
+ documents = _load_yaml(text)
299
+ if len(documents) > 1:
300
+ raise TransformError(
301
+ f"the input is a YAML stream of {len(documents)} documents, and yaml to json reads one document "
302
+ f"as one value; convert it to ndjson to write one line per document"
303
+ )
304
+ return _as_json(documents[0], "$") if documents else None
305
+
306
+
307
+ def _read_yaml_stream(text: str) -> list[JsonValue]:
308
+ """Read a YAML stream as its sequence of records, one per document."""
309
+ return [_as_json(document, f"$[{index}]") for index, document in enumerate(_load_yaml(text))]
310
+
311
+
312
+ def _step(path: str, key: str) -> str:
313
+ """Extend a path by one mapping key, bracketing the keys that are not plain names."""
314
+ return f"{path}.{key}" if re.fullmatch(r"[A-Za-z_]\w*", key) else f"{path}[{key!r}]"
315
+
316
+
317
+ def _unspellable(value: Any) -> str:
318
+ """Say what a YAML value is that JSON has no word for, and what to do about it."""
319
+ if isinstance(value, _Tagged):
320
+ return f"tagged {value.tag!r}, and JSON has no tags; drop the tag to convert what it holds"
321
+ if isinstance(value, datetime.datetime):
322
+ return "a timestamp, and JSON has no timestamp; quote it in the source to convert it as text"
323
+ if isinstance(value, datetime.date):
324
+ return "a date, and JSON has no date; quote it in the source to convert it as text"
325
+ if isinstance(value, set | frozenset):
326
+ return "a set, and JSON has no set; write it as a sequence to convert it"
327
+ if isinstance(value, bytes | bytearray):
328
+ return "binary, and JSON has no binary; quote it in the source to convert it as text"
329
+ return f"{type(value).__name__}, which JSON has no word for"
330
+
331
+
332
+ def _as_json(value: Any, path: str) -> JsonValue:
333
+ """Read a value the YAML loader built as JSON, refusing the first part of it JSON cannot spell."""
334
+ if value is None or isinstance(value, str | bool | int):
335
+ return value
336
+ if isinstance(value, float):
337
+ if math.isnan(value) or math.isinf(value):
338
+ raise TransformError(
339
+ f"the YAML number at {path} is {value}, and JSON writes no nan or infinity; "
340
+ f"quote it in the source to convert it as text"
341
+ )
342
+ return value
343
+ if isinstance(value, list):
344
+ items = cast("Sequence[Any]", value)
345
+ return [_as_json(item, f"{path}[{index}]") for index, item in enumerate(items)]
346
+ if isinstance(value, dict):
347
+ record: dict[str, JsonValue] = {}
348
+ for key, item in cast("Mapping[Any, Any]", value).items():
349
+ if not isinstance(key, str):
350
+ raise TransformError(
351
+ f"the YAML mapping at {path} is keyed by {key!r}, and a JSON key is a string; "
352
+ f"quote the key in the source to convert it"
353
+ )
354
+ record[key] = _as_json(item, _step(path, key))
355
+ return record
356
+ raise TransformError(f"the YAML value at {path} is {_unspellable(value)}")
357
+
358
+
359
+ def _dump_yaml(value: JsonValue) -> bytes:
360
+ """Write one JSON value as one YAML document, in block style and in the order it arrived."""
361
+ return yaml.safe_dump(value, sort_keys=False, allow_unicode=True, default_flow_style=False).encode()
362
+
363
+
364
+ def _dump_yaml_stream(records: list[JsonValue]) -> str:
365
+ """Write a sequence of records as a YAML stream, each record its own document."""
366
+ return yaml.safe_dump_all(
367
+ records, sort_keys=False, allow_unicode=True, default_flow_style=False, explicit_start=True
368
+ )
369
+
370
+
371
+ def _refuse_doctype(text: str) -> None:
372
+ """Refuse a document type declaration, walking the prolog so a comment holding the word is not one.
373
+
374
+ Nothing here reads a DTD, so nothing here resolves an entity, and no input reaches a file
375
+ or a URL the document names.
376
+ """
377
+ position = 0
378
+ while position < len(text):
379
+ rest = text[position:]
380
+ position += len(rest) - len(rest.lstrip())
381
+ if text.startswith("<!--", position):
382
+ end = text.find("-->", position)
383
+ if end < 0:
384
+ return
385
+ position = end + 3
386
+ elif text.startswith("<?", position):
387
+ end = text.find("?>", position)
388
+ if end < 0:
389
+ return
390
+ position = end + 2
391
+ elif text[position : position + 9].upper() == "<!DOCTYPE":
392
+ raise TransformError(
393
+ "the input carries a <!DOCTYPE declaration, and convert.std reads XML without a DTD, "
394
+ "because a DTD is where entities and the files they name enter a document"
395
+ )
396
+ else:
397
+ return
398
+
399
+
400
+ def _read_xml(text: str, target_format: str) -> bytes:
401
+ """Read the document as JSON, or stream the root's children as one record per line."""
402
+ _refuse_doctype(text)
403
+ if target_format == "ndjson":
404
+ return "".join(f"{json.dumps(record, separators=(',', ':'))}\n" for record in _stream_records(text)).encode()
405
+ root = _parse_xml(text)
406
+ return _dump_json({root.tag: _element_value(root, root.tag)})
407
+
408
+
409
+ def _parse_xml(text: str) -> ET.Element:
410
+ """Parse the whole document, naming where it stops being XML."""
411
+ try:
412
+ return ET.fromstring(text.encode())
413
+ except ET.ParseError as error:
414
+ raise TransformError(f"the input is not XML: {error}") from error
415
+
416
+
417
+ def _stream_records(text: str) -> Iterator[JsonValue]:
418
+ """Walk the root's children with iterparse, dropping each one as soon as it is written."""
419
+ events = ET.iterparse(io.BytesIO(text.encode()), events=("start", "end"))
420
+ root: ET.Element | None = None
421
+ depth = 0
422
+ number = 0
423
+ try:
424
+ for event, element in events:
425
+ if event == "start":
426
+ if root is None:
427
+ root = element
428
+ depth += 1
429
+ continue
430
+ depth -= 1
431
+ if depth != 1 or root is None:
432
+ continue
433
+ number += 1
434
+ if (root.text or "").strip():
435
+ raise TransformError(
436
+ f"the element at {root.tag} has text beside its child elements, and the records "
437
+ f"xml to ndjson writes are those children"
438
+ )
439
+ yield _element_value(element, f"{root.tag}/{element.tag}[{number}]")
440
+ # The root keeps every child the walk has seen, so it holds the whole document
441
+ # unless each record is dropped once it is written.
442
+ root.clear()
443
+ except ET.ParseError as error:
444
+ raise TransformError(f"the input is not XML: {error}") from error
445
+
446
+
447
+ def _element_value(element: ET.Element, path: str) -> JsonValue:
448
+ """Read one element as its object, or as the string or null an element with nothing else is."""
449
+ text = (element.text or "").strip()
450
+ attributes: dict[str, JsonValue] = {f"{ATTRIBUTE_PREFIX}{name}": value for name, value in element.attrib.items()}
451
+ children = list(element)
452
+ if not children:
453
+ if not attributes:
454
+ return text or None
455
+ return {**attributes, TEXT_KEY: text} if text else attributes
456
+ if text or any((child.tail or "").strip() for child in children):
457
+ raise TransformError(
458
+ f"the element at {path} has text beside its child elements, and this mapping keeps an "
459
+ f"element's text under {TEXT_KEY!r} only where the element has no children"
460
+ )
461
+ value: dict[str, JsonValue] = dict(attributes)
462
+ for number, child in enumerate(children, start=1):
463
+ read = _element_value(child, f"{path}/{child.tag}[{number}]")
464
+ if child.tag not in value:
465
+ value[child.tag] = read
466
+ elif isinstance(value[child.tag], list):
467
+ cast("list[JsonValue]", value[child.tag]).append(read)
468
+ else:
469
+ value[child.tag] = [value[child.tag], read]
470
+ return value
471
+
472
+
473
+ def _write_xml(value: JsonValue) -> bytes:
474
+ """Write one JSON object of a single key as the document that key names the root of."""
475
+ if not isinstance(value, dict) or len(value) != 1:
476
+ shape = f"an object of {len(value)} keys" if isinstance(value, dict) else _named(value)
477
+ raise TransformError(
478
+ f"json to xml writes one root element, so the input has to be an object of exactly one key "
479
+ f"naming it, and this one is {shape}"
480
+ )
481
+ tag, content = next(iter(value.items()))
482
+ root = _element(tag, content, f"$.{tag}")
483
+ written: bytes = ET.tostring(root, encoding="utf-8", xml_declaration=True)
484
+ return written + b"\n"
485
+
486
+
487
+ def _check_xml_name(name: str, path: str, what: str) -> None:
488
+ """Refuse a key that is no XML name, because this mapping writes a key as a tag or an attribute."""
489
+ if not XML_NAME.match(name):
490
+ raise TransformError(f"the key {name!r} at {path} is no XML name, and this mapping writes a key as {what}")
491
+
492
+
493
+ def _element(tag: str, value: JsonValue, path: str) -> ET.Element:
494
+ """Write one JSON value as the element its key names."""
495
+ _check_xml_name(tag, path, "an element's tag")
496
+ element = ET.Element(tag)
497
+ if isinstance(value, dict):
498
+ _fill_element(element, value, path)
499
+ return element
500
+ if isinstance(value, list):
501
+ raise TransformError(
502
+ f"the value at {path} is an array of arrays, and a tag repeated is as deep as a repeat goes"
503
+ )
504
+ element.text = _text(value, path)
505
+ return element
506
+
507
+
508
+ def _text(value: JsonValue, path: str) -> str | None:
509
+ """Write one scalar as element text or an attribute value, in its JSON spelling."""
510
+ if value is None:
511
+ return None
512
+ if isinstance(value, str):
513
+ return value
514
+ if isinstance(value, dict | list):
515
+ raise TransformError(
516
+ f"the value at {path} is {_named(value)}, and an XML attribute and an element's text hold text"
517
+ )
518
+ return json.dumps(value)
519
+
520
+
521
+ def _fill_element(element: ET.Element, value: dict[str, JsonValue], path: str) -> None:
522
+ """Fill an element from its object: attributes, then text or children, never both."""
523
+ children = {key: item for key, item in value.items() if not key.startswith(ATTRIBUTE_PREFIX) and key != TEXT_KEY}
524
+ if TEXT_KEY in value and children:
525
+ raise TransformError(
526
+ f"the object at {path} has both {TEXT_KEY!r} and child keys, and this mapping keeps an "
527
+ f"element's text under {TEXT_KEY!r} only where the element has no children"
528
+ )
529
+ for key, item in value.items():
530
+ if key.startswith(ATTRIBUTE_PREFIX):
531
+ name = key[len(ATTRIBUTE_PREFIX) :]
532
+ _check_xml_name(name, path, "an attribute's name")
533
+ attribute = _text(item, _step(path, key))
534
+ if attribute is None:
535
+ raise TransformError(f"the attribute {key!r} at {path} is null, and an XML attribute holds text")
536
+ element.set(name, attribute)
537
+ elif key == TEXT_KEY:
538
+ element.text = _text(item, _step(path, key))
539
+ for key, item in children.items():
540
+ _append_children(element, key, item, _step(path, key))
541
+
542
+
543
+ def _append_children(element: ET.Element, tag: str, value: JsonValue, path: str) -> None:
544
+ """Append the child or children one key stands for, a list being that tag repeated."""
545
+ if not isinstance(value, list):
546
+ element.append(_element(tag, value, path))
547
+ return
548
+ if not value:
549
+ raise TransformError(
550
+ f"the value at {path} is an empty array, and no elements at all is no key at all; "
551
+ f"drop the key to convert the object"
552
+ )
553
+ for index, item in enumerate(value):
554
+ element.append(_element(tag, item, f"{path}[{index}]"))