0

Transform data

by
Published today

Reshape JSON or XML with a JMESPath expression (jmespath.py, spec built-in functions only): extract fields, filter, sort, flatten and merge lists, build new objects. Literals need backticks or quotes: [?priority > `2`], [?status=='open'] (a bare word is a field name). Pass previous results as data (e.g. {"tickets": results.a, "users": results.b}); a string in data is parsed as JSON (or XML). For large inputs, pass a JSON or XML file from the workspace object storage as s3_file instead; it is still parsed whole in worker memory. input_format xml parses XML first (xmltodict): attributes become "@name" keys and namespaced tags "ns:tag" keys (quote both in expressions), values are strings or null for an empty element (compare and sort numbers with to_number, as strings sort lexically), and an element that appears once is an object (wrap with to_array). With the default expression @ the step just converts XML to JSON. write_to_s3 stores the result as JSON in object storage and returns its S3 object, which a later step takes as s3_file. Syntax errors repeat the expression with a caret under the failing column. Validated live on Windmill with MinIO object storage (JSON and XML flow steps, merging, S3 round trip, error cases).

Script helper Verified

The script

Submitted by hugo989 Python3
Verified 4 hours ago
1
# py312
2
# requirements:
3
# jmespath==1.1.0
4
# xmltodict==1.0.4
5
# wmill==1.851.0
6

7
import json
8
from typing import Literal, Optional
9
from xml.parsers.expat import ExpatError
10

11
import jmespath
12
import wmill
13
import xmltodict
14
from wmill import S3Object
15

16

17
def main(
18
    data=None,
19
    expression: str = "@",
20
    s3_file: Optional[S3Object] = None,
21
    input_format: Literal["json", "xml"] = "json",
22
    write_to_s3: bool = False,
23
):
24
    """
25
    Args:
26
        data: The value to transform, usually previous results, e.g. results.a, or {"tickets": results.a, "users": results.b} to combine several. JSON or XML text is parsed first.
27
        expression: JMESPath expression applied to the input. @ (the default) returns the parsed input unchanged.
28
        s3_file: A JSON or XML file in the workspace object storage, read instead of data for large inputs.
29
        input_format: xml parses XML text into JSON first: attributes become "@name" keys, text next to attributes "#text", repeated elements lists.
30
        write_to_s3: Write the result as a JSON file to the workspace object storage and return its S3 object instead.
31
    """
32
    # Compile first so a bad expression fails before any input is read. Syntax errors
33
    # repeat the expression with a caret under the failing column.
34
    query = jmespath.compile(expression or "@")
35

36
    source = "data"
37
    if s3_file and s3_file.get("s3"):
38
        if data not in (None, "", {}, []):
39
            raise ValueError("Pass either data or s3_file, not both")
40
        # The file is still parsed whole in memory (JMESPath needs the full tree):
41
        # object storage keeps it out of job args and results, not out of RAM.
42
        data = wmill.load_s3_file(s3_file)
43
        source = f"s3_file {s3_file['s3']}"
44

45
    if input_format == "xml":
46
        if not isinstance(data, (str, bytes)):
47
            raise ValueError(f"input_format is xml but {source} is not XML text")
48
        try:
49
            data = xmltodict.parse(data)
50
        except (ExpatError, ValueError) as e:  # ValueError: entity declarations
51
            raise ValueError(f"{source} is not valid XML: {e}") from None
52
    elif isinstance(data, (str, bytes)):
53
        try:
54
            data = json.loads(data)
55
        except (json.JSONDecodeError, UnicodeDecodeError) as e:
56
            raise ValueError(f"{source} is not valid JSON: {e}") from None
57

58
    try:
59
        result = query.search(data)
60
    except TypeError as e:
61
        # jmespath raises on a string/number comparison, typically an XML value.
62
        raise ValueError(
63
            f"{e}. If a string is compared with a number, convert it with to_number(...)"
64
        ) from None
65

66
    if write_to_s3:
67
        return wmill.write_s3_file(
68
            None, json.dumps(result).encode(), content_type="application/json"
69
        )
70
    return result
71