dirigent-block-base 0.17.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.
- dirigent_block_base/__init__.py +48 -0
- dirigent_block_base/clock.py +221 -0
- dirigent_block_base/convert_std.py +535 -0
- dirigent_block_base/log_notifier.py +44 -0
- dirigent_block_base/logging.py +63 -0
- dirigent_block_base/messages.py +155 -0
- dirigent_block_base/pipelines.py +162 -0
- dirigent_block_base/py.typed +0 -0
- dirigent_block_base/report.py +86 -0
- dirigent_block_base/validate.py +81 -0
- dirigent_block_base/values.py +40 -0
- dirigent_block_base-0.17.0.dist-info/METADATA +28 -0
- dirigent_block_base-0.17.0.dist-info/RECORD +16 -0
- dirigent_block_base-0.17.0.dist-info/WHEEL +4 -0
- dirigent_block_base-0.17.0.dist-info/entry_points.txt +3 -0
- dirigent_block_base-0.17.0.dist-info/licenses/LICENSE +18 -0
|
@@ -0,0 +1,535 @@
|
|
|
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_block_base.messages import (
|
|
17
|
+
CSV_HEADER_REPEATED,
|
|
18
|
+
CSV_HEADER_UNNAMED,
|
|
19
|
+
CSV_NESTED_VALUE,
|
|
20
|
+
CSV_ROW_NOT_AN_OBJECT,
|
|
21
|
+
CSV_ROW_TOO_WIDE,
|
|
22
|
+
LINE_NOT_JSON,
|
|
23
|
+
NOT_A_JSON_ARRAY,
|
|
24
|
+
NOT_JSON,
|
|
25
|
+
NOT_UTF8,
|
|
26
|
+
NOT_XML,
|
|
27
|
+
NOT_YAML,
|
|
28
|
+
XML_ELEMENT_HAS_TEXT,
|
|
29
|
+
XML_EMPTY_ARRAY,
|
|
30
|
+
XML_HAS_A_DOCTYPE,
|
|
31
|
+
XML_KEY_IS_NO_NAME,
|
|
32
|
+
XML_NESTED_ARRAY,
|
|
33
|
+
XML_NULL_ATTRIBUTE,
|
|
34
|
+
XML_ONE_ROOT,
|
|
35
|
+
XML_ROOT_HAS_TEXT,
|
|
36
|
+
XML_TEXT_AND_CHILDREN,
|
|
37
|
+
XML_VALUE_IS_NOT_TEXT,
|
|
38
|
+
YAML_KEY_NOT_A_STRING,
|
|
39
|
+
YAML_NOT_A_NUMBER,
|
|
40
|
+
YAML_STREAM,
|
|
41
|
+
YAML_UNSPELLABLE,
|
|
42
|
+
)
|
|
43
|
+
from dirigent_plugin import Converter, TransformError
|
|
44
|
+
|
|
45
|
+
#: The three spellings of a sequence of records, which convert between each other freely.
|
|
46
|
+
SEQUENCE_FORMATS: Final = ("json", "ndjson", "csv")
|
|
47
|
+
|
|
48
|
+
#: Every pair this engine re-encodes, source first. The frame refuses everything else.
|
|
49
|
+
PAIRS: Final = frozenset(
|
|
50
|
+
{(source, target) for source in SEQUENCE_FORMATS for target in SEQUENCE_FORMATS if source != target}
|
|
51
|
+
| {("json", "yaml"), ("yaml", "json"), ("ndjson", "yaml"), ("yaml", "ndjson")}
|
|
52
|
+
| {("json", "xml"), ("xml", "json"), ("xml", "ndjson")}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
#: What a target format calls one element of the sequence it writes.
|
|
56
|
+
UNIT: Final = {"json": "element", "ndjson": "line", "csv": "row", "yaml": "document"}
|
|
57
|
+
|
|
58
|
+
#: The key an element's attribute takes, prefixed so it can never collide with a child's tag.
|
|
59
|
+
ATTRIBUTE_PREFIX: Final = "@"
|
|
60
|
+
|
|
61
|
+
#: The key an element's text takes where the element also has attributes.
|
|
62
|
+
TEXT_KEY: Final = "#text"
|
|
63
|
+
|
|
64
|
+
#: An XML name, in ElementTree's spelling: a local name, optionally behind a ``{uri}``.
|
|
65
|
+
XML_NAME: Final = re.compile(r"(\{[^{}]+\})?[A-Za-z_][\w.\-]*\Z")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _named(value: JsonValue) -> str:
|
|
69
|
+
"""Say what a JSON value is, so a refusal names what arrived and not only what was wanted."""
|
|
70
|
+
if isinstance(value, dict):
|
|
71
|
+
return "an object"
|
|
72
|
+
if isinstance(value, list):
|
|
73
|
+
return "an array"
|
|
74
|
+
if isinstance(value, str):
|
|
75
|
+
return "a string"
|
|
76
|
+
# Before the number check, because a bool is an int in Python and is not one in JSON.
|
|
77
|
+
if isinstance(value, bool):
|
|
78
|
+
return "a boolean"
|
|
79
|
+
if value is None:
|
|
80
|
+
return "null"
|
|
81
|
+
return "a number"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class StdConverter(Converter):
|
|
85
|
+
"""Re-encodes records and values between json, ndjson, csv, yaml and xml.
|
|
86
|
+
|
|
87
|
+
json, ndjson and csv say the same thing: a sequence of records. json says it as one
|
|
88
|
+
array, ndjson as one JSON value per line, and csv as a header and its rows. So a
|
|
89
|
+
conversion between those three is reading the sequence out of one spelling and writing
|
|
90
|
+
it in another, and everything the engine refuses there is a source that is not that
|
|
91
|
+
sequence.
|
|
92
|
+
|
|
93
|
+
csv is the format that carries less than the other two. A cell is text, so everything
|
|
94
|
+
read out of a csv is a string, and a record with a nested value has no csv spelling at
|
|
95
|
+
all. Both are refusals rather than conversions, because a codec that guesses a number
|
|
96
|
+
out of text, or flattens an object into a cell, has not preserved the content.
|
|
97
|
+
|
|
98
|
+
yaml is read and written two ways, because YAML spells two things. A YAML document is
|
|
99
|
+
one value, so yaml and json trade one whole value either way and a config file becomes
|
|
100
|
+
the object it describes. A YAML stream is many documents, so yaml and ndjson trade one
|
|
101
|
+
document per line. A YAML value JSON has no word for -- a date, a set, binary, a tagged
|
|
102
|
+
node, a mapping key that is not a string, a number that is nan or infinite -- is refused
|
|
103
|
+
naming its path, because writing it as text would be a guess about what it meant.
|
|
104
|
+
|
|
105
|
+
xml carries one root element, and the mapping is the one that round-trips: an element is
|
|
106
|
+
an object, an attribute is a key prefixed with ``@``, the text is ``#text`` where the
|
|
107
|
+
element has attributes and the bare string where it has none, a child element is a key of
|
|
108
|
+
its tag, and a tag that repeats is a list in document order. The prefix is what makes it
|
|
109
|
+
reversible: ``@`` cannot start an XML name, so an attribute and a child of the same name
|
|
110
|
+
never collide and every key says which it came from. An empty element is null, everything
|
|
111
|
+
read out of an element is text the way everything read out of a csv is, and mixed content
|
|
112
|
+
-- text beside child elements -- is refused naming the element, because the mapping has
|
|
113
|
+
nowhere to put it. json to xml therefore wants an object of exactly one key, which names
|
|
114
|
+
the root element.
|
|
115
|
+
|
|
116
|
+
xml to ndjson is the streaming direction: the root's children are the records, one line
|
|
117
|
+
each, read with ``iterparse`` and dropped as they are written, so a document far larger
|
|
118
|
+
than the records it holds converts. It has no reverse, because a line carries no root
|
|
119
|
+
element name to write one under; convert to json and name the root there.
|
|
120
|
+
|
|
121
|
+
No document type declaration is read. A DTD is where entities and external files enter an
|
|
122
|
+
XML document, so one is refused rather than parsed.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
kind = "std"
|
|
126
|
+
summary = "Convert between json, ndjson, csv, yaml and xml."
|
|
127
|
+
pairs = PAIRS
|
|
128
|
+
|
|
129
|
+
def convert(self, source: bytes, *, source_format: str, target_format: str) -> bytes:
|
|
130
|
+
"""Re-encode the payload, by whole value where yaml and json meet and by sequence otherwise."""
|
|
131
|
+
text = _decode(source)
|
|
132
|
+
if (source_format, target_format) == ("yaml", "json"):
|
|
133
|
+
return _dump_json(_read_yaml_document(text))
|
|
134
|
+
if (source_format, target_format) == ("json", "yaml"):
|
|
135
|
+
return _dump_yaml(_read_json_value(text))
|
|
136
|
+
if source_format == "xml":
|
|
137
|
+
return _read_xml(text, target_format)
|
|
138
|
+
if target_format == "xml":
|
|
139
|
+
return _write_xml(_read_json_value(text))
|
|
140
|
+
records = _read(text, source_format, target_format)
|
|
141
|
+
return _write(records, target_format).encode()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _decode(source: bytes) -> str:
|
|
145
|
+
"""Read the payload as UTF-8, refusing bytes that are not, at the byte that is not."""
|
|
146
|
+
try:
|
|
147
|
+
return source.decode("utf-8")
|
|
148
|
+
except UnicodeDecodeError as error:
|
|
149
|
+
raise TransformError(NOT_UTF8.render(reason=error.reason, position=error.start)) from error
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _dump_json(value: JsonValue) -> bytes:
|
|
153
|
+
"""Write one JSON value, compactly, as the bytes the frame hands on."""
|
|
154
|
+
return json.dumps(value, separators=(",", ":")).encode()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _read(text: str, source_format: str, target_format: str) -> list[JsonValue]:
|
|
158
|
+
"""Read the sequence of records the source format spells out."""
|
|
159
|
+
if source_format == "json":
|
|
160
|
+
return _read_json(text, target_format)
|
|
161
|
+
if source_format == "ndjson":
|
|
162
|
+
return _read_ndjson(text)
|
|
163
|
+
if source_format == "yaml":
|
|
164
|
+
return _read_yaml_stream(text)
|
|
165
|
+
return _read_csv(text)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _read_json_value(text: str) -> JsonValue:
|
|
169
|
+
"""Read one JSON value of any shape, for the targets that carry a value rather than a sequence."""
|
|
170
|
+
try:
|
|
171
|
+
parsed: JsonValue = json.loads(text)
|
|
172
|
+
except ValueError as error:
|
|
173
|
+
raise TransformError(NOT_JSON.render(detail=str(error))) from error
|
|
174
|
+
return parsed
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _read_json(text: str, target_format: str) -> list[JsonValue]:
|
|
178
|
+
"""Read one JSON array, refusing any other JSON value: a sequence is what is being converted."""
|
|
179
|
+
parsed = _read_json_value(text)
|
|
180
|
+
if not isinstance(parsed, list):
|
|
181
|
+
raise TransformError(
|
|
182
|
+
NOT_A_JSON_ARRAY.render(target_format=target_format, unit=UNIT[target_format], described=_named(parsed))
|
|
183
|
+
)
|
|
184
|
+
return parsed
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _read_ndjson(text: str) -> list[JsonValue]:
|
|
188
|
+
"""Read one JSON value per line, skipping blank lines and naming the line that is not JSON."""
|
|
189
|
+
records: list[JsonValue] = []
|
|
190
|
+
for number, line in enumerate(text.splitlines(), start=1):
|
|
191
|
+
if not line.strip():
|
|
192
|
+
continue
|
|
193
|
+
try:
|
|
194
|
+
records.append(json.loads(line))
|
|
195
|
+
except ValueError as error:
|
|
196
|
+
raise TransformError(LINE_NOT_JSON.render(number=number, detail=str(error))) from error
|
|
197
|
+
return records
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _read_csv(text: str) -> list[JsonValue]:
|
|
201
|
+
"""Read the header row and its rows, as objects whose every value is a string."""
|
|
202
|
+
rows = csv.reader(io.StringIO(text, newline=""))
|
|
203
|
+
header = next(rows, None)
|
|
204
|
+
if header is None:
|
|
205
|
+
return []
|
|
206
|
+
_check_header(header)
|
|
207
|
+
records: list[JsonValue] = []
|
|
208
|
+
for number, row in enumerate(rows, start=1):
|
|
209
|
+
if not row:
|
|
210
|
+
continue
|
|
211
|
+
if len(row) > len(header):
|
|
212
|
+
raise TransformError(CSV_ROW_TOO_WIDE.render(number=number))
|
|
213
|
+
records.append({name: row[index] if index < len(row) else "" for index, name in enumerate(header)})
|
|
214
|
+
return records
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _columns_phrase(positions: list[int]) -> str:
|
|
218
|
+
"""Word one or more column positions, counting from one."""
|
|
219
|
+
listed = ", ".join(str(position) for position in positions)
|
|
220
|
+
return f"column {listed}" if len(positions) == 1 else f"columns {listed}"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _check_header(header: list[str]) -> None:
|
|
224
|
+
"""Refuse a header that leaves a name empty or repeats one.
|
|
225
|
+
|
|
226
|
+
A record key names its column, so neither has a record spelling: keeping one of two
|
|
227
|
+
columns that share a name, or keying a column by the empty string, drops content while
|
|
228
|
+
reporting success.
|
|
229
|
+
"""
|
|
230
|
+
positions: dict[str, list[int]] = {}
|
|
231
|
+
for index, name in enumerate(header, start=1):
|
|
232
|
+
positions.setdefault(name, []).append(index)
|
|
233
|
+
unnamed = positions.pop("", None)
|
|
234
|
+
if unnamed is not None:
|
|
235
|
+
raise TransformError(CSV_HEADER_UNNAMED.render(columns=_columns_phrase(unnamed)))
|
|
236
|
+
repeated = [(name, where) for name, where in positions.items() if len(where) > 1]
|
|
237
|
+
if repeated:
|
|
238
|
+
listed = "; ".join(f"{name!r} at {_columns_phrase(where)}" for name, where in repeated)
|
|
239
|
+
raise TransformError(CSV_HEADER_REPEATED.render(listed=listed))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _write(records: list[JsonValue], target_format: str) -> str:
|
|
243
|
+
"""Write the sequence of records in the target format."""
|
|
244
|
+
if target_format == "json":
|
|
245
|
+
return json.dumps(records, separators=(",", ":"))
|
|
246
|
+
if target_format == "ndjson":
|
|
247
|
+
return "".join(f"{json.dumps(record, separators=(',', ':'))}\n" for record in records)
|
|
248
|
+
if target_format == "yaml":
|
|
249
|
+
return _dump_yaml_stream(records)
|
|
250
|
+
return _write_csv(records)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _write_csv(records: list[JsonValue]) -> str:
|
|
254
|
+
"""Write a header of every key any row has, in first-seen order, and one row per record."""
|
|
255
|
+
rows: list[dict[str, JsonValue]] = []
|
|
256
|
+
header: list[str] = []
|
|
257
|
+
for number, record in enumerate(records, start=1):
|
|
258
|
+
if not isinstance(record, dict):
|
|
259
|
+
raise TransformError(CSV_ROW_NOT_AN_OBJECT.render(number=number, described=_named(record)))
|
|
260
|
+
rows.append(record)
|
|
261
|
+
header.extend(key for key in record if key not in header)
|
|
262
|
+
out = io.StringIO(newline="")
|
|
263
|
+
writer = csv.writer(out, lineterminator="\n")
|
|
264
|
+
writer.writerow(header)
|
|
265
|
+
for number, row in enumerate(rows, start=1):
|
|
266
|
+
writer.writerow([_cell(row.get(key), number, key) for key in header])
|
|
267
|
+
return out.getvalue()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _cell(value: JsonValue, number: int, key: str) -> str:
|
|
271
|
+
"""Render one value as csv text, refusing the nested ones csv has no spelling for."""
|
|
272
|
+
if isinstance(value, dict | list):
|
|
273
|
+
raise TransformError(CSV_NESTED_VALUE.render(number=number, key=repr(key)))
|
|
274
|
+
if value is None:
|
|
275
|
+
return ""
|
|
276
|
+
if isinstance(value, str):
|
|
277
|
+
return value
|
|
278
|
+
return json.dumps(value)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class _Tagged:
|
|
282
|
+
"""A node carrying a YAML tag the safe loader has no type for, kept so a refusal can name its path."""
|
|
283
|
+
|
|
284
|
+
def __init__(self, tag: str) -> None:
|
|
285
|
+
self.tag = tag
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class _JsonSafeLoader(yaml.SafeLoader):
|
|
289
|
+
"""The safe loader, with an unknown tag built into a marker rather than refused without a path."""
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _construct_tagged(loader: yaml.SafeLoader, suffix: str, node: yaml.Node) -> _Tagged:
|
|
293
|
+
"""Build the marker for any tag the safe loader has no constructor of its own for."""
|
|
294
|
+
return _Tagged(node.tag)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
yaml.add_multi_constructor("", _construct_tagged, Loader=_JsonSafeLoader)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _load_yaml(text: str) -> list[Any]:
|
|
301
|
+
"""Read every document of a YAML stream with the safe loader, naming where it stops."""
|
|
302
|
+
try:
|
|
303
|
+
return list(yaml.load_all(text, Loader=_JsonSafeLoader))
|
|
304
|
+
except yaml.YAMLError as error:
|
|
305
|
+
raise TransformError(NOT_YAML.render(detail=" ".join(str(error).split()))) from error
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _read_yaml_document(text: str) -> JsonValue:
|
|
309
|
+
"""Read one YAML document as one value, refusing a stream that holds more than one."""
|
|
310
|
+
documents = _load_yaml(text)
|
|
311
|
+
if len(documents) > 1:
|
|
312
|
+
raise TransformError(YAML_STREAM.render(counted=len(documents)))
|
|
313
|
+
return _as_json(documents[0], "$") if documents else None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _read_yaml_stream(text: str) -> list[JsonValue]:
|
|
317
|
+
"""Read a YAML stream as its sequence of records, one per document."""
|
|
318
|
+
return [_as_json(document, f"$[{index}]") for index, document in enumerate(_load_yaml(text))]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _step(path: str, key: str) -> str:
|
|
322
|
+
"""Extend a path by one mapping key, bracketing the keys that are not plain names."""
|
|
323
|
+
return f"{path}.{key}" if re.fullmatch(r"[A-Za-z_]\w*", key) else f"{path}[{key!r}]"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _unspellable(value: Any) -> str:
|
|
327
|
+
"""Say what a YAML value is that JSON has no word for, and what to do about it."""
|
|
328
|
+
if isinstance(value, _Tagged):
|
|
329
|
+
return f"tagged {value.tag!r}, and JSON has no tags; drop the tag to convert what it holds"
|
|
330
|
+
if isinstance(value, datetime.datetime):
|
|
331
|
+
return "a timestamp, and JSON has no timestamp; quote it in the source to convert it as text"
|
|
332
|
+
if isinstance(value, datetime.date):
|
|
333
|
+
return "a date, and JSON has no date; quote it in the source to convert it as text"
|
|
334
|
+
if isinstance(value, set | frozenset):
|
|
335
|
+
return "a set, and JSON has no set; write it as a sequence to convert it"
|
|
336
|
+
if isinstance(value, bytes | bytearray):
|
|
337
|
+
return "binary, and JSON has no binary; quote it in the source to convert it as text"
|
|
338
|
+
return f"{type(value).__name__}, which JSON has no word for"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _as_json(value: Any, path: str) -> JsonValue:
|
|
342
|
+
"""Read a value the YAML loader built as JSON, refusing the first part of it JSON cannot spell."""
|
|
343
|
+
if value is None or isinstance(value, str | bool | int):
|
|
344
|
+
return value
|
|
345
|
+
if isinstance(value, float):
|
|
346
|
+
if math.isnan(value) or math.isinf(value):
|
|
347
|
+
raise TransformError(YAML_NOT_A_NUMBER.render(path=path, value=value))
|
|
348
|
+
return value
|
|
349
|
+
if isinstance(value, list):
|
|
350
|
+
items = cast("Sequence[Any]", value)
|
|
351
|
+
return [_as_json(item, f"{path}[{index}]") for index, item in enumerate(items)]
|
|
352
|
+
if isinstance(value, dict):
|
|
353
|
+
record: dict[str, JsonValue] = {}
|
|
354
|
+
for key, item in cast("Mapping[Any, Any]", value).items():
|
|
355
|
+
if not isinstance(key, str):
|
|
356
|
+
raise TransformError(YAML_KEY_NOT_A_STRING.render(path=path, key=repr(key)))
|
|
357
|
+
record[key] = _as_json(item, _step(path, key))
|
|
358
|
+
return record
|
|
359
|
+
raise TransformError(YAML_UNSPELLABLE.render(path=path, described=_unspellable(value)))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _dump_yaml(value: JsonValue) -> bytes:
|
|
363
|
+
"""Write one JSON value as one YAML document, in block style and in the order it arrived."""
|
|
364
|
+
return yaml.safe_dump(value, sort_keys=False, allow_unicode=True, default_flow_style=False).encode()
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _dump_yaml_stream(records: list[JsonValue]) -> str:
|
|
368
|
+
"""Write a sequence of records as a YAML stream, each record its own document."""
|
|
369
|
+
return yaml.safe_dump_all(
|
|
370
|
+
records, sort_keys=False, allow_unicode=True, default_flow_style=False, explicit_start=True
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _refuse_doctype(text: str) -> None:
|
|
375
|
+
"""Refuse a document type declaration, walking the prolog so a comment holding the word is not one.
|
|
376
|
+
|
|
377
|
+
Nothing here reads a DTD, so nothing here resolves an entity, and no input reaches a file
|
|
378
|
+
or a URL the document names.
|
|
379
|
+
"""
|
|
380
|
+
position = 0
|
|
381
|
+
while position < len(text):
|
|
382
|
+
rest = text[position:]
|
|
383
|
+
position += len(rest) - len(rest.lstrip())
|
|
384
|
+
if text.startswith("<!--", position):
|
|
385
|
+
end = text.find("-->", position)
|
|
386
|
+
if end < 0:
|
|
387
|
+
return
|
|
388
|
+
position = end + 3
|
|
389
|
+
elif text.startswith("<?", position):
|
|
390
|
+
end = text.find("?>", position)
|
|
391
|
+
if end < 0:
|
|
392
|
+
return
|
|
393
|
+
position = end + 2
|
|
394
|
+
elif text[position : position + 9].upper() == "<!DOCTYPE":
|
|
395
|
+
raise TransformError(XML_HAS_A_DOCTYPE.render())
|
|
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(NOT_XML.render(detail=str(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(XML_ROOT_HAS_TEXT.render(tag=root.tag))
|
|
436
|
+
yield _element_value(element, f"{root.tag}/{element.tag}[{number}]")
|
|
437
|
+
# The root keeps every child the walk has seen, so it holds the whole document
|
|
438
|
+
# unless each record is dropped once it is written.
|
|
439
|
+
root.clear()
|
|
440
|
+
except ET.ParseError as error:
|
|
441
|
+
raise TransformError(NOT_XML.render(detail=str(error))) from error
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _element_value(element: ET.Element, path: str) -> JsonValue:
|
|
445
|
+
"""Read one element as its object, or as the string or null an element with nothing else is."""
|
|
446
|
+
text = (element.text or "").strip()
|
|
447
|
+
attributes: dict[str, JsonValue] = {f"{ATTRIBUTE_PREFIX}{name}": value for name, value in element.attrib.items()}
|
|
448
|
+
children = list(element)
|
|
449
|
+
if not children:
|
|
450
|
+
if not attributes:
|
|
451
|
+
return text or None
|
|
452
|
+
return {**attributes, TEXT_KEY: text} if text else attributes
|
|
453
|
+
if text or any((child.tail or "").strip() for child in children):
|
|
454
|
+
raise TransformError(XML_ELEMENT_HAS_TEXT.render(path=path, text_key=repr(TEXT_KEY)))
|
|
455
|
+
value: dict[str, JsonValue] = dict(attributes)
|
|
456
|
+
for number, child in enumerate(children, start=1):
|
|
457
|
+
read = _element_value(child, f"{path}/{child.tag}[{number}]")
|
|
458
|
+
if child.tag not in value:
|
|
459
|
+
value[child.tag] = read
|
|
460
|
+
elif isinstance(value[child.tag], list):
|
|
461
|
+
cast("list[JsonValue]", value[child.tag]).append(read)
|
|
462
|
+
else:
|
|
463
|
+
value[child.tag] = [value[child.tag], read]
|
|
464
|
+
return value
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _write_xml(value: JsonValue) -> bytes:
|
|
468
|
+
"""Write one JSON object of a single key as the document that key names the root of."""
|
|
469
|
+
if not isinstance(value, dict) or len(value) != 1:
|
|
470
|
+
shape = f"an object of {len(value)} keys" if isinstance(value, dict) else _named(value)
|
|
471
|
+
raise TransformError(XML_ONE_ROOT.render(shape=shape))
|
|
472
|
+
tag, content = next(iter(value.items()))
|
|
473
|
+
root = _element(tag, content, f"$.{tag}")
|
|
474
|
+
written: bytes = ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
|
475
|
+
return written + b"\n"
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _check_xml_name(name: str, path: str, what: str) -> None:
|
|
479
|
+
"""Refuse a key that is no XML name, because this mapping writes a key as a tag or an attribute."""
|
|
480
|
+
if not XML_NAME.match(name):
|
|
481
|
+
raise TransformError(XML_KEY_IS_NO_NAME.render(key=repr(name), path=path, what=what))
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _element(tag: str, value: JsonValue, path: str) -> ET.Element:
|
|
485
|
+
"""Write one JSON value as the element its key names."""
|
|
486
|
+
_check_xml_name(tag, path, "an element's tag")
|
|
487
|
+
element = ET.Element(tag)
|
|
488
|
+
if isinstance(value, dict):
|
|
489
|
+
_fill_element(element, value, path)
|
|
490
|
+
return element
|
|
491
|
+
if isinstance(value, list):
|
|
492
|
+
raise TransformError(XML_NESTED_ARRAY.render(path=path))
|
|
493
|
+
element.text = _text(value, path)
|
|
494
|
+
return element
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _text(value: JsonValue, path: str) -> str | None:
|
|
498
|
+
"""Write one scalar as element text or an attribute value, in its JSON spelling."""
|
|
499
|
+
if value is None:
|
|
500
|
+
return None
|
|
501
|
+
if isinstance(value, str):
|
|
502
|
+
return value
|
|
503
|
+
if isinstance(value, dict | list):
|
|
504
|
+
raise TransformError(XML_VALUE_IS_NOT_TEXT.render(path=path, described=_named(value)))
|
|
505
|
+
return json.dumps(value)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _fill_element(element: ET.Element, value: dict[str, JsonValue], path: str) -> None:
|
|
509
|
+
"""Fill an element from its object: attributes, then text or children, never both."""
|
|
510
|
+
children = {key: item for key, item in value.items() if not key.startswith(ATTRIBUTE_PREFIX) and key != TEXT_KEY}
|
|
511
|
+
if TEXT_KEY in value and children:
|
|
512
|
+
raise TransformError(XML_TEXT_AND_CHILDREN.render(path=path, text_key=repr(TEXT_KEY)))
|
|
513
|
+
for key, item in value.items():
|
|
514
|
+
if key.startswith(ATTRIBUTE_PREFIX):
|
|
515
|
+
name = key[len(ATTRIBUTE_PREFIX) :]
|
|
516
|
+
_check_xml_name(name, path, "an attribute's name")
|
|
517
|
+
attribute = _text(item, _step(path, key))
|
|
518
|
+
if attribute is None:
|
|
519
|
+
raise TransformError(XML_NULL_ATTRIBUTE.render(key=repr(key), path=path))
|
|
520
|
+
element.set(name, attribute)
|
|
521
|
+
elif key == TEXT_KEY:
|
|
522
|
+
element.text = _text(item, _step(path, key))
|
|
523
|
+
for key, item in children.items():
|
|
524
|
+
_append_children(element, key, item, _step(path, key))
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _append_children(element: ET.Element, tag: str, value: JsonValue, path: str) -> None:
|
|
528
|
+
"""Append the child or children one key stands for, a list being that tag repeated."""
|
|
529
|
+
if not isinstance(value, list):
|
|
530
|
+
element.append(_element(tag, value, path))
|
|
531
|
+
return
|
|
532
|
+
if not value:
|
|
533
|
+
raise TransformError(XML_EMPTY_ARRAY.render(path=path))
|
|
534
|
+
for index, item in enumerate(value):
|
|
535
|
+
element.append(_element(tag, item, f"{path}[{index}]"))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The channel every instance already has: an alert written to the process log.
|
|
2
|
+
|
|
3
|
+
It is the one notifier with no connection kind beside it, because there is no credential to
|
|
4
|
+
mint: an instance with nothing configured still says something.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import ClassVar, Literal
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from dirigent_common import BlockModel
|
|
13
|
+
from dirigent_plugin import AlertMessage, Notifier
|
|
14
|
+
|
|
15
|
+
#: Must match the logger chain dirigent-core configures.
|
|
16
|
+
ALERT_LOGGER = "dirigent.alert"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LogNotifierConfig(BlockModel):
|
|
20
|
+
"""What the log notifier needs, which is nothing but the level to write at."""
|
|
21
|
+
|
|
22
|
+
level: Literal["debug", "info", "warning", "error"] = "warning"
|
|
23
|
+
"""The process-log level an alert is written at; a channel with no credential has little else to say."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LogNotifier(Notifier):
|
|
27
|
+
"""Writes an alert to the process log, so an instance with no channel configured still says something."""
|
|
28
|
+
|
|
29
|
+
id: ClassVar[str] = "log"
|
|
30
|
+
config_model: ClassVar[type[BaseModel]] = LogNotifierConfig
|
|
31
|
+
|
|
32
|
+
async def send(self, message: AlertMessage, config: BaseModel) -> None:
|
|
33
|
+
"""Write one alert through the shared structlog chain, at the configured level."""
|
|
34
|
+
settings = LogNotifierConfig.model_validate(config.model_dump())
|
|
35
|
+
logger: structlog.stdlib.BoundLogger = structlog.get_logger(ALERT_LOGGER)
|
|
36
|
+
write = getattr(logger, settings.level, logger.warning)
|
|
37
|
+
write(
|
|
38
|
+
message.subject,
|
|
39
|
+
event_kind=message.event,
|
|
40
|
+
run_id=str(message.run_id) if message.run_id else None,
|
|
41
|
+
pipeline=message.pipeline,
|
|
42
|
+
url=message.url,
|
|
43
|
+
body=message.body,
|
|
44
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""``log.write``: one line into the run's own log, and the value carried on."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, JsonValue
|
|
7
|
+
|
|
8
|
+
from dirigent_common import BlockModel
|
|
9
|
+
from dirigent_plugin import Operator, OperatorSpec, RemoteHandle, StepContext
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LogLevel(StrEnum):
|
|
13
|
+
"""The levels the run's log carries, one per method of the logger a block is handed."""
|
|
14
|
+
|
|
15
|
+
DEBUG = "debug"
|
|
16
|
+
INFO = "info"
|
|
17
|
+
WARNING = "warning"
|
|
18
|
+
ERROR = "error"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LogWriteConfig(BlockModel):
|
|
22
|
+
"""What to say, how loudly, and what to say it about."""
|
|
23
|
+
|
|
24
|
+
message: str
|
|
25
|
+
"""The line the run's log carries."""
|
|
26
|
+
|
|
27
|
+
level: LogLevel = LogLevel.INFO
|
|
28
|
+
"""Which level the entry is written at, which is what a log filter selects on."""
|
|
29
|
+
|
|
30
|
+
value: JsonValue = None
|
|
31
|
+
"""A value recorded as a field of the entry, and passed on as this step's output."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LogWriteOutput(BlockModel):
|
|
35
|
+
"""The value that was logged, so the step is a pass-through rather than a dead end."""
|
|
36
|
+
|
|
37
|
+
value: JsonValue = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LogWriteOperator(Operator[LogWriteConfig, LogWriteOutput]):
|
|
41
|
+
"""Writes one entry to the run's log and hands its value on.
|
|
42
|
+
|
|
43
|
+
The smallest sink there is: ``storage.read`` then ``log.write`` shows what was read,
|
|
44
|
+
without a shell step or a receiver to POST it to. It touches nothing but the run's own
|
|
45
|
+
timeline, so it runs on any worker with nothing on the allowlist.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
spec = OperatorSpec(id="log.write", summary="Write a line to the run's log.", idempotent=True)
|
|
49
|
+
config_model: ClassVar[type[BaseModel]] = LogWriteConfig
|
|
50
|
+
output_model: ClassVar[type[BaseModel]] = LogWriteOutput
|
|
51
|
+
|
|
52
|
+
async def execute(self, config: LogWriteConfig, ctx: StepContext) -> LogWriteOutput | RemoteHandle:
|
|
53
|
+
"""Write the entry at the configured level, and pass the value through."""
|
|
54
|
+
match config.level:
|
|
55
|
+
case LogLevel.DEBUG:
|
|
56
|
+
ctx.log.debug(config.message, value=config.value)
|
|
57
|
+
case LogLevel.INFO:
|
|
58
|
+
ctx.log.info(config.message, value=config.value)
|
|
59
|
+
case LogLevel.WARNING:
|
|
60
|
+
ctx.log.warning(config.message, value=config.value)
|
|
61
|
+
case LogLevel.ERROR:
|
|
62
|
+
ctx.log.error(config.message, value=config.value)
|
|
63
|
+
return LogWriteOutput(value=config.value)
|