lvxml 0.2.1__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.
- lvxml/__init__.py +32 -0
- lvxml/__main__.py +9 -0
- lvxml/_checks.py +110 -0
- lvxml/_cli.py +167 -0
- lvxml/_decode.py +109 -0
- lvxml/_dump.py +106 -0
- lvxml/_encode.py +158 -0
- lvxml/_errors.py +16 -0
- lvxml/_load.py +59 -0
- lvxml/_node.py +85 -0
- lvxml/_numbers.py +83 -0
- lvxml/_parse.py +168 -0
- lvxml/_schema.py +114 -0
- lvxml/_treejson.py +162 -0
- lvxml/py.typed +0 -0
- lvxml-0.2.1.dist-info/METADATA +254 -0
- lvxml-0.2.1.dist-info/RECORD +20 -0
- lvxml-0.2.1.dist-info/WHEEL +4 -0
- lvxml-0.2.1.dist-info/entry_points.txt +2 -0
- lvxml-0.2.1.dist-info/licenses/LICENSE +21 -0
lvxml/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Codec between LabVIEW's Flatten/Unflatten XML schema and native Python values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._checks import validate
|
|
6
|
+
from ._decode import from_lv_xml, from_lv_xml_all, to_value
|
|
7
|
+
from ._dump import dump
|
|
8
|
+
from ._encode import to_lv_xml, to_node
|
|
9
|
+
from ._errors import LVXMLError
|
|
10
|
+
from ._node import LVDATA_NAMESPACE, Document, Node
|
|
11
|
+
from ._parse import parse
|
|
12
|
+
from ._schema import Array, Cluster, LVEnum
|
|
13
|
+
|
|
14
|
+
__version__ = "0.2.1"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"LVDATA_NAMESPACE",
|
|
18
|
+
"Array",
|
|
19
|
+
"Cluster",
|
|
20
|
+
"Document",
|
|
21
|
+
"LVEnum",
|
|
22
|
+
"LVXMLError",
|
|
23
|
+
"Node",
|
|
24
|
+
"dump",
|
|
25
|
+
"from_lv_xml",
|
|
26
|
+
"from_lv_xml_all",
|
|
27
|
+
"parse",
|
|
28
|
+
"to_lv_xml",
|
|
29
|
+
"to_node",
|
|
30
|
+
"to_value",
|
|
31
|
+
"validate",
|
|
32
|
+
]
|
lvxml/__main__.py
ADDED
lvxml/_checks.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Consistency rules beyond the XSD's shape: counts that agree and values that parse."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
|
|
8
|
+
from ._errors import LVXMLError
|
|
9
|
+
from ._node import Document, Node
|
|
10
|
+
from ._numbers import parse_bool, parse_complex, parse_float, parse_int
|
|
11
|
+
from ._parse import parse
|
|
12
|
+
from ._schema import COMPLEX_TAGS, ENUM_RANGES, FLOAT_TAGS, INT_RANGES
|
|
13
|
+
|
|
14
|
+
# An empty array with leading non-zero sizes (such as 1000x0) decodes to nested empty lists;
|
|
15
|
+
# more than this many is refused so a tiny input cannot demand unbounded memory.
|
|
16
|
+
MAX_EMPTY_ROWS = 100_000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def problems(node: Node) -> list[str]:
|
|
20
|
+
"""Return what is wrong with `node` itself, not looking inside its items."""
|
|
21
|
+
found: list[str] = []
|
|
22
|
+
try:
|
|
23
|
+
_check_val(node)
|
|
24
|
+
except ValueError as exc:
|
|
25
|
+
found.append(str(exc))
|
|
26
|
+
if node.tag == "Array":
|
|
27
|
+
total = math.prod(node.dims)
|
|
28
|
+
count = len(node.items)
|
|
29
|
+
if total == 0 and count > 1:
|
|
30
|
+
found.append(f"array Dimsize {_dims(node)} is empty but holds {count} elements")
|
|
31
|
+
if total == 0 and _empty_rows(node.dims) > MAX_EMPTY_ROWS:
|
|
32
|
+
found.append(f"empty array Dimsize {_dims(node)} needs over {MAX_EMPTY_ROWS} lists")
|
|
33
|
+
elif total and count != total:
|
|
34
|
+
found.append(f"array Dimsize {_dims(node)} needs {total} elements, found {count}")
|
|
35
|
+
tags = {item.tag for item in node.items}
|
|
36
|
+
if len(tags) > 1:
|
|
37
|
+
found.append(f"array mixes element types {sorted(tags)}")
|
|
38
|
+
if node.tag == "Cluster" and node.num_elts != len(node.items):
|
|
39
|
+
found.append(f"cluster NumElts {node.num_elts} does not match {len(node.items)} elements")
|
|
40
|
+
return found
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _empty_rows(dims: tuple[int, ...]) -> int:
|
|
44
|
+
rows, prefix = 0, 1
|
|
45
|
+
for dim in dims[:-1]:
|
|
46
|
+
if dim == 0:
|
|
47
|
+
break
|
|
48
|
+
prefix *= dim
|
|
49
|
+
rows += prefix
|
|
50
|
+
if rows > MAX_EMPTY_ROWS:
|
|
51
|
+
break
|
|
52
|
+
return rows
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _dims(node: Node) -> str:
|
|
56
|
+
return "x".join(str(dim) for dim in node.dims)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _check_val(node: Node) -> None:
|
|
60
|
+
text = node.val if node.val is not None else ""
|
|
61
|
+
if node.tag in FLOAT_TAGS:
|
|
62
|
+
parse_float(text)
|
|
63
|
+
elif node.tag in COMPLEX_TAGS:
|
|
64
|
+
parse_complex(text)
|
|
65
|
+
elif node.tag in INT_RANGES:
|
|
66
|
+
parse_int(text, node.tag)
|
|
67
|
+
elif node.tag == "Boolean":
|
|
68
|
+
parse_bool(text)
|
|
69
|
+
elif node.tag in ENUM_RANGES:
|
|
70
|
+
index = parse_int(text, node.tag)
|
|
71
|
+
if index >= len(node.choices):
|
|
72
|
+
raise ValueError(f"enum index {index} is past its {len(node.choices)} choices")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def child_path(parent: str, node: Node) -> str:
|
|
76
|
+
here = f"{parent}/{node.tag}" if parent else node.tag
|
|
77
|
+
return f"{here}[{node.name}]" if node.name else here
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def walk(node: Node, parent: str) -> Iterator[tuple[str, Node]]:
|
|
81
|
+
"""Yield every node under and including `node` with its path, depth first."""
|
|
82
|
+
path = child_path(parent, node)
|
|
83
|
+
yield path, node
|
|
84
|
+
for item in node.items:
|
|
85
|
+
yield from walk(item, path)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate(text: str | bytes) -> list[str]:
|
|
89
|
+
"""Return every problem found in LabVIEW XML `text`; an empty list means it is valid.
|
|
90
|
+
|
|
91
|
+
A document that does not have the shape LVXMLSchema.xsd defines yields one problem,
|
|
92
|
+
since its structure cannot be trusted further. A well-shaped one is then checked
|
|
93
|
+
value by value: numbers that parse and fit their type, enum indexes inside the
|
|
94
|
+
choice list, and ``NumElts`` and ``Dimsize`` that match the elements present.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
document = parse(text)
|
|
98
|
+
except LVXMLError as exc:
|
|
99
|
+
return [str(exc)]
|
|
100
|
+
return document_problems(document)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def document_problems(document: Document) -> list[str]:
|
|
104
|
+
"""Return every value-level problem in an already parsed `document`."""
|
|
105
|
+
root = "LVData" if document.wrapped else ""
|
|
106
|
+
found: list[str] = []
|
|
107
|
+
for top in document.values:
|
|
108
|
+
for path, node in walk(top, root):
|
|
109
|
+
found.extend(f"{path}: {problem}" for problem in problems(node))
|
|
110
|
+
return found
|
lvxml/_cli.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""The `lvxml` command: convert LabVIEW XML to and from JSON, and validate it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import codecs
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import NoReturn
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from ._checks import document_problems, validate
|
|
15
|
+
from ._decode import to_value
|
|
16
|
+
from ._dump import dump
|
|
17
|
+
from ._errors import LVXMLError
|
|
18
|
+
from ._node import Document
|
|
19
|
+
from ._numbers import format_float
|
|
20
|
+
from ._parse import parse
|
|
21
|
+
|
|
22
|
+
EXAMPLES = """\
|
|
23
|
+
examples:
|
|
24
|
+
echo "<I32><Name>n</Name><Val>7</Val></I32>" | lvxml to-json --values -
|
|
25
|
+
lvxml to-json data.xml -o data.json lossless tree, every name and raw value kept
|
|
26
|
+
lvxml to-json --values data.xml plain values: numbers, strings, lists, dicts
|
|
27
|
+
lvxml to-xml data.json -o data.xml tree JSON back to LabVIEW XML
|
|
28
|
+
lvxml validate data.xml other.xml check shape and values, exit 1 on any problem
|
|
29
|
+
|
|
30
|
+
A FILE of "-" reads standard input. Errors go to standard error with exit status 1.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Parser(argparse.ArgumentParser):
|
|
35
|
+
def error(self, message: str) -> NoReturn:
|
|
36
|
+
self.print_usage(sys.stderr)
|
|
37
|
+
self.exit(1, f"{self.prog}: error: {message}\n")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
41
|
+
parser = _Parser(
|
|
42
|
+
prog="lvxml",
|
|
43
|
+
description="Read, write and check LabVIEW Flatten To XML data.",
|
|
44
|
+
epilog=EXAMPLES,
|
|
45
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument("--version", action="version", version=f"lvxml {__version__}")
|
|
48
|
+
commands = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
|
49
|
+
to_json = commands.add_parser("to-json", help="convert LabVIEW XML to JSON")
|
|
50
|
+
to_json.add_argument("file", metavar="FILE", help="LabVIEW XML file, or - for stdin")
|
|
51
|
+
to_json.add_argument("--values", action="store_true", help="emit plain values, not the tree")
|
|
52
|
+
to_xml = commands.add_parser("to-xml", help="convert tree JSON (from to-json) to LabVIEW XML")
|
|
53
|
+
to_xml.add_argument("file", metavar="FILE", help="tree JSON file, or - for stdin")
|
|
54
|
+
to_xml.add_argument(
|
|
55
|
+
"--newline", choices=["crlf", "lf"], default="crlf", help="line break (default: crlf)"
|
|
56
|
+
)
|
|
57
|
+
check = commands.add_parser("validate", help="check LabVIEW XML files")
|
|
58
|
+
check.add_argument("files", metavar="FILE", nargs="+", help="LabVIEW XML files")
|
|
59
|
+
for sub in (to_json, to_xml, check):
|
|
60
|
+
sub.add_argument(
|
|
61
|
+
"--encoding", default="utf-8", help="text encoding of files (default: utf-8)"
|
|
62
|
+
)
|
|
63
|
+
for sub in (to_json, to_xml):
|
|
64
|
+
sub.add_argument("-o", "--output", metavar="OUT", help="write here instead of stdout")
|
|
65
|
+
return parser
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read(path: str, encoding: str) -> str:
|
|
69
|
+
if path == "-":
|
|
70
|
+
data = sys.stdin.buffer.read()
|
|
71
|
+
else:
|
|
72
|
+
with open(path, "rb") as handle:
|
|
73
|
+
data = handle.read()
|
|
74
|
+
try:
|
|
75
|
+
return data.decode(encoding)
|
|
76
|
+
except UnicodeDecodeError as exc:
|
|
77
|
+
where = "stdin" if path == "-" else path
|
|
78
|
+
raise LVXMLError(f"{where} is not valid {encoding} ({exc.reason}); try --encoding") from exc
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _write(text: str, output: str | None, encoding: str) -> None:
|
|
82
|
+
try:
|
|
83
|
+
data = text.encode(encoding)
|
|
84
|
+
except UnicodeEncodeError as exc:
|
|
85
|
+
bad = exc.object[exc.start : exc.end]
|
|
86
|
+
raise LVXMLError(f"{bad!r} cannot be written as {encoding}; try --encoding utf-8") from exc
|
|
87
|
+
if output is None:
|
|
88
|
+
sys.stdout.buffer.write(data)
|
|
89
|
+
sys.stdout.buffer.flush()
|
|
90
|
+
else:
|
|
91
|
+
with open(output, "wb") as handle:
|
|
92
|
+
handle.write(data)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _plain(value: object) -> object:
|
|
96
|
+
"""Make a value strict JSON: complex as {"re", "im"}, NaN and Inf as strings."""
|
|
97
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
98
|
+
return format_float(value, "DBL")
|
|
99
|
+
if isinstance(value, complex):
|
|
100
|
+
return {"re": _plain(value.real), "im": _plain(value.imag)}
|
|
101
|
+
if isinstance(value, list):
|
|
102
|
+
return [_plain(item) for item in value]
|
|
103
|
+
if isinstance(value, dict):
|
|
104
|
+
return {key: _plain(item) for key, item in value.items()}
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _to_json(args: argparse.Namespace) -> int:
|
|
109
|
+
document = parse(_read(args.file, args.encoding))
|
|
110
|
+
if args.values:
|
|
111
|
+
found = document_problems(document)
|
|
112
|
+
if found:
|
|
113
|
+
raise LVXMLError(found[0])
|
|
114
|
+
data: object = [
|
|
115
|
+
{"name": node.name, "type": node.tag, "value": _plain(to_value(node))}
|
|
116
|
+
for node in document.values
|
|
117
|
+
]
|
|
118
|
+
else:
|
|
119
|
+
data = document.to_dict()
|
|
120
|
+
text = json.dumps(data, indent=2, ensure_ascii=False, allow_nan=False) + "\n"
|
|
121
|
+
_write(text, args.output, "utf-8")
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _to_xml(args: argparse.Namespace) -> int:
|
|
126
|
+
try:
|
|
127
|
+
data = json.loads(_read(args.file, args.encoding))
|
|
128
|
+
except json.JSONDecodeError as exc:
|
|
129
|
+
raise LVXMLError(f"invalid JSON: {exc}") from exc
|
|
130
|
+
newline = "\r\n" if args.newline == "crlf" else "\n"
|
|
131
|
+
_write(dump(Document.from_dict(data), newline=newline), args.output, args.encoding)
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _validate(args: argparse.Namespace) -> int:
|
|
136
|
+
status = 0
|
|
137
|
+
for path in args.files:
|
|
138
|
+
try:
|
|
139
|
+
found = validate(_read(path, args.encoding))
|
|
140
|
+
except (LVXMLError, OSError) as exc:
|
|
141
|
+
found = [str(exc)]
|
|
142
|
+
for problem in found:
|
|
143
|
+
print(f"{path}: {problem}", file=sys.stderr)
|
|
144
|
+
if found:
|
|
145
|
+
status = 1
|
|
146
|
+
else:
|
|
147
|
+
print(f"{path}: OK")
|
|
148
|
+
return status
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
152
|
+
"""Run the command line with `argv` (default: ``sys.argv[1:]``); return the exit status."""
|
|
153
|
+
parser = _build_parser()
|
|
154
|
+
args = parser.parse_args(argv)
|
|
155
|
+
try:
|
|
156
|
+
codecs.lookup(args.encoding)
|
|
157
|
+
except LookupError:
|
|
158
|
+
parser.error(f"unknown encoding {args.encoding!r}")
|
|
159
|
+
handlers = {"to-json": _to_json, "to-xml": _to_xml, "validate": _validate}
|
|
160
|
+
try:
|
|
161
|
+
return handlers[args.command](args)
|
|
162
|
+
except (LVXMLError, OSError) as exc:
|
|
163
|
+
print(f"lvxml: error: {exc}", file=sys.stderr)
|
|
164
|
+
return 1
|
|
165
|
+
except RecursionError:
|
|
166
|
+
print("lvxml: error: the input is nested too deeply", file=sys.stderr)
|
|
167
|
+
return 1
|
lvxml/_decode.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Turn LabVIEW Flatten-To-XML text, or a parsed tree, into native Python values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._checks import child_path, problems
|
|
6
|
+
from ._errors import LVXMLError
|
|
7
|
+
from ._node import Node
|
|
8
|
+
from ._numbers import parse_bool, parse_complex, parse_float, parse_int
|
|
9
|
+
from ._parse import parse
|
|
10
|
+
from ._schema import COMPLEX_TAGS, ENUM_RANGES, FLOAT_TAGS, INT_RANGES, Value
|
|
11
|
+
from ._treejson import check_shape
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def from_lv_xml(text: str | bytes) -> Value:
|
|
15
|
+
"""Parse LabVIEW flattened XML into the Python value of its first value element.
|
|
16
|
+
|
|
17
|
+
Inside an ``<LVData>`` file wrapper the ``<Version>`` header is skipped; use
|
|
18
|
+
`from_lv_xml_all` when the file holds several values.
|
|
19
|
+
"""
|
|
20
|
+
document = parse(text)
|
|
21
|
+
if not document.values:
|
|
22
|
+
raise LVXMLError("<LVData> holds no value")
|
|
23
|
+
root = "LVData" if document.wrapped else ""
|
|
24
|
+
return _value(document.values[0], root)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def from_lv_xml_all(text: str | bytes) -> list[Value]:
|
|
28
|
+
"""Parse LabVIEW flattened XML into the Python values of every value element, in order."""
|
|
29
|
+
document = parse(text)
|
|
30
|
+
root = "LVData" if document.wrapped else ""
|
|
31
|
+
return [_value(node, root) for node in document.values]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def to_value(node: Node) -> Value:
|
|
35
|
+
"""Convert one typed `Node` into its Python value.
|
|
36
|
+
|
|
37
|
+
Integers become ``int``, ``SGL``/``DBL``/``EXT`` ``float``, ``CSG``/``CDB``/``CXT``
|
|
38
|
+
``complex``, ``Boolean`` ``bool``; ``String``, ``Path``, ``DAQChannel`` and a
|
|
39
|
+
``Refnum``'s ``<Val>`` stay ``str``; an enum becomes its selected choice label. An
|
|
40
|
+
``Array`` becomes a list, nested once per dimension, and a ``Cluster`` a dict keyed by
|
|
41
|
+
field name. ``LvVariant``, ``Timestamp``, ``WDT``, ``DynamicData``, ``DigitalData``
|
|
42
|
+
and the waveform elements become the list of their items' values.
|
|
43
|
+
"""
|
|
44
|
+
check_shape(node, "")
|
|
45
|
+
try:
|
|
46
|
+
return _value(node, "")
|
|
47
|
+
except RecursionError:
|
|
48
|
+
raise LVXMLError("the tree is nested too deeply to convert") from None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _value(node: Node, parent: str) -> Value:
|
|
52
|
+
path = child_path(parent, node)
|
|
53
|
+
found = problems(node)
|
|
54
|
+
if found:
|
|
55
|
+
raise LVXMLError(found[0], path)
|
|
56
|
+
tag, text = node.tag, node.val if node.val is not None else ""
|
|
57
|
+
if tag in FLOAT_TAGS:
|
|
58
|
+
return parse_float(text)
|
|
59
|
+
if tag in COMPLEX_TAGS:
|
|
60
|
+
return parse_complex(text)
|
|
61
|
+
if tag in INT_RANGES:
|
|
62
|
+
return parse_int(text, tag)
|
|
63
|
+
if tag == "Boolean":
|
|
64
|
+
return parse_bool(text)
|
|
65
|
+
if tag in ENUM_RANGES:
|
|
66
|
+
return node.choices[parse_int(text, tag)]
|
|
67
|
+
if tag == "Array":
|
|
68
|
+
return _array(node, path)
|
|
69
|
+
if tag == "Cluster":
|
|
70
|
+
return _cluster(node, path)
|
|
71
|
+
if node.items or node.val is None:
|
|
72
|
+
return [_value(item, path) for item in node.items]
|
|
73
|
+
return text
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _cluster(node: Node, path: str) -> dict[str, Value]:
|
|
77
|
+
fields: dict[str, Value] = {}
|
|
78
|
+
for item in node.items:
|
|
79
|
+
if item.name in fields:
|
|
80
|
+
raise LVXMLError(
|
|
81
|
+
f"duplicate field name {item.name!r} cannot map to a dict; "
|
|
82
|
+
"use lvxml.parse for positional access",
|
|
83
|
+
path,
|
|
84
|
+
)
|
|
85
|
+
fields[item.name] = _value(item, path)
|
|
86
|
+
return fields
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _array(node: Node, path: str) -> list[Value]:
|
|
90
|
+
dims = node.dims
|
|
91
|
+
if 0 in dims:
|
|
92
|
+
for item in node.items:
|
|
93
|
+
_value(item, path) # the default element must still be a valid value
|
|
94
|
+
return _empty(dims)
|
|
95
|
+
flat = [_value(item, path) for item in node.items]
|
|
96
|
+
return _reshape(flat, dims)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _empty(dims: tuple[int, ...]) -> list[Value]:
|
|
100
|
+
if dims[0] == 0 or len(dims) == 1:
|
|
101
|
+
return []
|
|
102
|
+
return [_empty(dims[1:]) for _ in range(dims[0])]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _reshape(flat: list[Value], dims: tuple[int, ...]) -> list[Value]:
|
|
106
|
+
if len(dims) == 1:
|
|
107
|
+
return flat
|
|
108
|
+
step = len(flat) // dims[0]
|
|
109
|
+
return [_reshape(flat[i * step : (i + 1) * step], dims[1:]) for i in range(dims[0])]
|
lvxml/_dump.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Write the typed tree back as text, in the layout LabVIEW's own XML writer uses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from ._errors import LVXMLError
|
|
8
|
+
from ._node import Document, Node
|
|
9
|
+
from ._treejson import check_shape
|
|
10
|
+
|
|
11
|
+
# The declaration LabVIEW's XML file writer emits before <LVData>.
|
|
12
|
+
DECLARATION = "<?xml version='1.0' standalone='yes' ?>"
|
|
13
|
+
|
|
14
|
+
# Characters XML 1.0 cannot carry at all, even escaped.
|
|
15
|
+
_ILLEGAL = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def dump(
|
|
19
|
+
tree: Document | Node,
|
|
20
|
+
*,
|
|
21
|
+
newline: str = "\r\n",
|
|
22
|
+
indent: str = "",
|
|
23
|
+
declaration: bool | None = None,
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Serialize a `Document` or a single `Node` as LabVIEW XML.
|
|
26
|
+
|
|
27
|
+
The defaults reproduce the layout of a file LabVIEW 2026 wrote: one element per line,
|
|
28
|
+
CRLF line breaks, no indentation, empty elements written as ``<Name></Name>`` and no
|
|
29
|
+
line break after the last element. Only ``&``, ``<`` and ``>`` are escaped.
|
|
30
|
+
`declaration` defaults to true for an ``<LVData>`` document and false otherwise. A
|
|
31
|
+
`Node` is written bare, as Flatten To XML outputs it.
|
|
32
|
+
"""
|
|
33
|
+
document = tree if isinstance(tree, Document) else Document((tree,), False, None)
|
|
34
|
+
if not document.wrapped and len(document.items) != 1:
|
|
35
|
+
raise LVXMLError("a bare document holds exactly one value; set wrapped=True")
|
|
36
|
+
root = "LVData" if document.wrapped else ""
|
|
37
|
+
for node in document.items:
|
|
38
|
+
if node.tag == "Version" and document.wrapped:
|
|
39
|
+
if node != Node("Version", val=node.val) or node.val is None:
|
|
40
|
+
raise LVXMLError("a Version node only takes a val", root)
|
|
41
|
+
else:
|
|
42
|
+
try:
|
|
43
|
+
check_shape(node, root)
|
|
44
|
+
except RecursionError:
|
|
45
|
+
raise LVXMLError("the tree is nested too deeply to write", root) from None
|
|
46
|
+
lines: list[str] = []
|
|
47
|
+
if declaration if declaration is not None else document.wrapped:
|
|
48
|
+
lines.append(DECLARATION)
|
|
49
|
+
xmlns = f' xmlns="{_escape(document.namespace, attr=True)}"' if document.namespace else ""
|
|
50
|
+
if document.wrapped:
|
|
51
|
+
lines.append(f"<LVData{xmlns}>")
|
|
52
|
+
for node in document.items:
|
|
53
|
+
_write(node, lines, indent, 1 if indent else 0, "")
|
|
54
|
+
lines.append("</LVData>")
|
|
55
|
+
else:
|
|
56
|
+
_write(document.items[0], lines, indent, 0, xmlns)
|
|
57
|
+
return newline.join(lines)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _escape(text: str, attr: bool = False) -> str:
|
|
61
|
+
bad = _ILLEGAL.search(text)
|
|
62
|
+
if bad:
|
|
63
|
+
raise LVXMLError(f"character U+{ord(bad.group()):04X} cannot be written in XML")
|
|
64
|
+
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
65
|
+
return text.replace('"', """) if attr else text
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _leaf(tag: str, text: str, pad: str) -> str:
|
|
69
|
+
return f"{pad}<{tag}>{_escape(text)}</{tag}>"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _write(node: Node, lines: list[str], indent: str, depth: int, xmlns: str) -> None:
|
|
73
|
+
pad = indent * depth
|
|
74
|
+
inner = indent * (depth + 1)
|
|
75
|
+
if node.tag == "Version":
|
|
76
|
+
lines.append(_leaf("Version", node.val or "", pad))
|
|
77
|
+
return
|
|
78
|
+
lines.append(f"{pad}<{node.tag}{xmlns}>")
|
|
79
|
+
lines.append(_leaf("Name", node.name, inner))
|
|
80
|
+
lines.extend(_leaf("Choice", choice, inner) for choice in node.choices)
|
|
81
|
+
if node.val is not None:
|
|
82
|
+
lines.append(_leaf("Val", node.val, inner))
|
|
83
|
+
_write_extra(node.extra, lines, inner, indent)
|
|
84
|
+
lines.extend(_leaf("Dimsize", str(dim), inner) for dim in node.dims)
|
|
85
|
+
if node.num_elts is not None:
|
|
86
|
+
lines.append(_leaf("NumElts", str(node.num_elts), inner))
|
|
87
|
+
for item in node.items:
|
|
88
|
+
_write(item, lines, indent, depth + 1, "")
|
|
89
|
+
lines.append(f"{pad}</{node.tag}>")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _write_extra(
|
|
93
|
+
extra: tuple[tuple[str, str], ...], lines: list[str], pad: str, indent: str
|
|
94
|
+
) -> None:
|
|
95
|
+
open_group = ""
|
|
96
|
+
for key, text in extra:
|
|
97
|
+
group, _, field = key.rpartition(".")
|
|
98
|
+
if group != open_group:
|
|
99
|
+
if open_group:
|
|
100
|
+
lines.append(f"{pad}</{open_group}>")
|
|
101
|
+
if group:
|
|
102
|
+
lines.append(f"{pad}<{group}>")
|
|
103
|
+
open_group = group
|
|
104
|
+
lines.append(_leaf(field, text, pad + indent if group else pad))
|
|
105
|
+
if open_group:
|
|
106
|
+
lines.append(f"{pad}</{open_group}>")
|